diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..0bb7ae248 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: ci +'on': + - push + - pull_request +jobs: + test: + name: Node ${{ matrix.node }} / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + node: + - '18' + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + - run: npm install + - run: npm run build --if-present + - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..61c6902cf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Release + +on: + push: + branches: + - master + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Cache + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('**/package.json') }} + restore-keys: | + ${{ runner.os }}-npm- + - name: Install dependencies + run: npm i + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Release + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npx semantic-release diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..2b2bab3bc --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,23 @@ +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '0 12 * * *' + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v8 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'Is this still relevant? If so, what is blocking it? Is there anything you can do to help move it forward?' + stale-pr-message: 'Is this still relevant? If so, what is blocking it? Is there anything you can do to help move it forward?' + exempt-issue-labels: accepted,blocked,bug,dependency,enhancement,'help wanted',question,security,meta + exempt-pr-labels: accepted,blocked,bug,dependency,enhancement,'help wanted',question,security,meta + stale-issue-label: 'stale' + stale-pr-label: 'stale' diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..504afef81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/.npmignore b/.npmignore new file mode 100644 index 000000000..ae28c2087 --- /dev/null +++ b/.npmignore @@ -0,0 +1,6 @@ +CONTRIBUTING.md +examples/ +img/ +test/ +tools/ +.github/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ede021414..000000000 --- a/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -language: node_js -node_js: - - "iojs" - - "0.12" - - "0.10" diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 000000000..3b930ae6f --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,74 @@ +# Authors + +#### Ordered by first contribution. + +- Feross Aboukhadijeh (feross@feross.org) +- Mathias Buus (mathiasbuus@gmail.com) +- thermatk (thermatk@thermatk.com) +- fisch0920 (fisch0920@gmail.com) +- Aliaksei Sapach (aliaksei.dreamsonic@gmail.com) +- John Hiesey (john@hiesey.com) +- hicom150 (necrox666@gmail.com) +- Theadd (pantallazo@gmail.com) +- Astro (astro@spaceboyz.net) +- Anthony MOI (xn1t0x@gmail.com) +- Max Ogden (max@maxogden.com) +- Sidd Sridharan (sidd@sidd.com) +- Nick Rafter (nicholas.rafter@gmail.com) +- zckevin (zckevinzc@gmail.com) +- Michael Williams (dinosaur@riseup.net) +- Garret Buell (gmbuell@gmail.com) +- Linus Unnebäck (linus@folkdatorn.se) +- Aram Drevekenin (aram@onetwotrade.com) +- Gustavo Rodrigues (qgustavor@gmail.com) +- Alex (alxmorais8@msn.com) +- Harsh Vakharia (harshjv@users.noreply.github.com) +- Yoann Ciabaud (yoann@sonora.io) +- Autarc (autarc@gmail.com) +- Diego Rodríguez Baquero (diegorbaquero@gmail.com) +- Kirill Fomichev (fanatid@ya.ru) +- Matt Bell (mappum@gmail.com) +- Diego Rodríguez (diegorbaquero@gmail.com) +- Philipp Henkel (henkel@users.noreply.github.com) +- jakefb (jacobafb@gmail.com) +- Nick Frost (nickfrostatx@gmail.com) +- ZunSThy (zunsthy@gmail.com) +- vijayanand nandam (vijay@cybrilla.com) +- Luigi Pinca (luigipinca@gmail.com) +- Diego R. B (diegorbaquero@gmail.com) +- greenkeeper[bot] (greenkeeper[bot]@users.noreply.github.com) +- hrafnkell orri sigurdsson (hrafnkellos@gmail.com) +- Brian Clifton (brian@clifton.me) +- James M Snell (jasnell@gmail.com) +- crapthings (crapthings@gmail.com) +- daiyu (qqdaiyu55@gmail.com) +- LI (kslrwang@gmail.com) +- Jimmy Wärting (jimmy@warting.se) +- Justin Kalland (justin@kalland.ch) +- greenkeeper[bot] (23040076+greenkeeper[bot]@users.noreply.github.com) +- Eric Guan (guanzo91@gmail.com) +- Koushik Dutta (koush@koushikdutta.com) +- KayleePop (34007889+KayleePop@users.noreply.github.com) +- Diego Rodriguez Baquero (diego.baquero@pager.com) +- Diego Rodríguez Baquero (github@diegorbaquero.com) +- Renovate Bot (bot@renovateapp.com) +- Diego Rodríguez Baquero (diego@mothership.com) +- Diego Rodriguez Baquero (diego@arc.io) +- semantic-release-bot (semantic-release-bot@martynus.net) +- renovate[bot] (29139614+renovate[bot]@users.noreply.github.com) +- Jocelyn Liu (yrliou@gmail.com) +- Bruce Hopkins (behopkinsjr@gmail.com) +- Ryan Finnie (ryan@finnie.org) +- Lookis (lookisliu@gmail.com) +- Paul Sharypov (pavloniym@gmail.com) +- Cas (6506529+ThaUnknown@users.noreply.github.com) +- Tom Snelling (tomsnelling8@gmail.com) +- Cas_ (6506529+ThaUnknown@users.noreply.github.com) +- Arsène Fougerouse (arsene582@gmail.com) +- Brad Marsden (silentbot1@gmail.com) +- krazak (krazak@vt.edu) +- Chocobozzz (chocobozzz@cpy.re) +- uriva (uriva@users.noreply.github.com) +- Subin Siby (mail@subinsb.com) + +#### Generated by tools/update-authors.sh. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..6b00b4dce --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,275 @@ +## [11.2.2](https://github.com/webtorrent/bittorrent-tracker/compare/v11.2.1...v11.2.2) (2025-09-06) + + +### Bug Fixes + +* export WebSocketTracker ([#558](https://github.com/webtorrent/bittorrent-tracker/issues/558)) ([1571551](https://github.com/webtorrent/bittorrent-tracker/commit/15715518decfed77d7888ba21d6ab592fa91cc85)) + +## [11.2.1](https://github.com/webtorrent/bittorrent-tracker/compare/v11.2.0...v11.2.1) (2025-01-19) + + +### Bug Fixes + +* http announce no left ([#548](https://github.com/webtorrent/bittorrent-tracker/issues/548)) ([3cd77f3](https://github.com/webtorrent/bittorrent-tracker/commit/3cd77f3e6f5b52f5d58adaf004b333cd2061a4da)) + +# [11.2.0](https://github.com/webtorrent/bittorrent-tracker/compare/v11.1.2...v11.2.0) (2024-12-28) + + +### Features + +* release [#539](https://github.com/webtorrent/bittorrent-tracker/issues/539) and [#535](https://github.com/webtorrent/bittorrent-tracker/issues/535) ([#544](https://github.com/webtorrent/bittorrent-tracker/issues/544)) ([e7de90c](https://github.com/webtorrent/bittorrent-tracker/commit/e7de90c0cbcfb41c9c53c5caf69cc37c6d3ef1e8)) + +## [11.1.2](https://github.com/webtorrent/bittorrent-tracker/compare/v11.1.1...v11.1.2) (2024-08-13) + + +### Bug Fixes + +* statuscode ([#526](https://github.com/webtorrent/bittorrent-tracker/issues/526)) ([e9d8f8c](https://github.com/webtorrent/bittorrent-tracker/commit/e9d8f8cd754ba26d86f32f9b8da0c0c4a3dcd646)) + +## [11.1.1](https://github.com/webtorrent/bittorrent-tracker/compare/v11.1.0...v11.1.1) (2024-07-01) + + +### Performance Improvements + +* drop clone ([#523](https://github.com/webtorrent/bittorrent-tracker/issues/523)) ([83a24ce](https://github.com/webtorrent/bittorrent-tracker/commit/83a24ce77fb1a96b7fe4c383ce92d7c28fc165a7)) + +# [11.1.0](https://github.com/webtorrent/bittorrent-tracker/compare/v11.0.2...v11.1.0) (2024-05-22) + + +### Bug Fixes + +* semantic release ([#520](https://github.com/webtorrent/bittorrent-tracker/issues/520)) ([428fb22](https://github.com/webtorrent/bittorrent-tracker/commit/428fb224f5666731332738032649f4448b2e1e4f)) + + +### Features + +* updated webrtc implementation ([#519](https://github.com/webtorrent/bittorrent-tracker/issues/519)) ([633d68a](https://github.com/webtorrent/bittorrent-tracker/commit/633d68a32c2c143fec0182317a9801dd1b64faef)) + +## [11.0.2](https://github.com/webtorrent/bittorrent-tracker/compare/v11.0.1...v11.0.2) (2024-03-12) + + +### Bug Fixes + +* **parse-http:** ignore announcements from peers with invalid announcement ports. ([#513](https://github.com/webtorrent/bittorrent-tracker/issues/513)) ([fe75272](https://github.com/webtorrent/bittorrent-tracker/commit/fe75272d51653e626583689081afb0b7aeadb84f)) + +## [11.0.1](https://github.com/webtorrent/bittorrent-tracker/compare/v11.0.0...v11.0.1) (2024-01-16) + + +### Bug Fixes + +* update build badge url ([#506](https://github.com/webtorrent/bittorrent-tracker/issues/506)) ([3f59b58](https://github.com/webtorrent/bittorrent-tracker/commit/3f59b58a020ea8c0926be135471a6666fe8e8b21)) + +# [11.0.0](https://github.com/webtorrent/bittorrent-tracker/compare/v10.0.12...v11.0.0) (2023-10-31) + + +### Features + +* **major:** drop simple-get ([#443](https://github.com/webtorrent/bittorrent-tracker/issues/443)) ([bce64e1](https://github.com/webtorrent/bittorrent-tracker/commit/bce64e155df6ff9fa605898cbf7498bf76188d8b)) + + +### BREAKING CHANGES + +* **major:** drop simple-get + +* perf: drop simple-get + +* feat: undici agent and socks + +* fix: undici as dev dependency + +* feat: require user passed proxy objects for http and ws + +* chore: include undici for tests + +## [10.0.12](https://github.com/webtorrent/bittorrent-tracker/compare/v10.0.11...v10.0.12) (2023-08-09) + + +### Bug Fixes + +* **deps:** update dependency bencode to v4 ([#487](https://github.com/webtorrent/bittorrent-tracker/issues/487)) ([aeccf9c](https://github.com/webtorrent/bittorrent-tracker/commit/aeccf9c1c4b9115fd23b4fe1a0ab990b5add0f17)) + +## [10.0.11](https://github.com/webtorrent/bittorrent-tracker/compare/v10.0.10...v10.0.11) (2023-08-01) + + +### Bug Fixes + +* mangled scrape infohashes ([#486](https://github.com/webtorrent/bittorrent-tracker/issues/486)) ([11cce83](https://github.com/webtorrent/bittorrent-tracker/commit/11cce83ddd858813f5684da8a116de4bee6e518b)) + +## [10.0.10](https://github.com/webtorrent/bittorrent-tracker/compare/v10.0.9...v10.0.10) (2023-06-16) + + +### Performance Improvements + +* use simple-peer/lite ([#475](https://github.com/webtorrent/bittorrent-tracker/issues/475)) ([5b8db06](https://github.com/webtorrent/bittorrent-tracker/commit/5b8db067e48cc81796728ff538d7ff6efafc59b8)) + +## [10.0.9](https://github.com/webtorrent/bittorrent-tracker/compare/v10.0.8...v10.0.9) (2023-06-16) + + +### Performance Improvements + +* use peer/lite ([#474](https://github.com/webtorrent/bittorrent-tracker/issues/474)) ([7c845f0](https://github.com/webtorrent/bittorrent-tracker/commit/7c845f030d07b1bf7060ab880b790ee85a8c7ac0)) + +## [10.0.8](https://github.com/webtorrent/bittorrent-tracker/compare/v10.0.7...v10.0.8) (2023-06-07) + + +### Bug Fixes + +* bigInt ([#472](https://github.com/webtorrent/bittorrent-tracker/issues/472)) ([d7061f7](https://github.com/webtorrent/bittorrent-tracker/commit/d7061f73b2ebff072e064971a5960749a7335bae)) + +## [10.0.7](https://github.com/webtorrent/bittorrent-tracker/compare/v10.0.6...v10.0.7) (2023-06-05) + + +### Bug Fixes + +* imports ([#471](https://github.com/webtorrent/bittorrent-tracker/issues/471)) ([a12022a](https://github.com/webtorrent/bittorrent-tracker/commit/a12022ac2c81d7fa3ecb81163852161e64199cf4)) + +## [10.0.6](https://github.com/webtorrent/bittorrent-tracker/compare/v10.0.5...v10.0.6) (2023-05-27) + + +### Bug Fixes + +* replace simple-peer with maintained one ([#466](https://github.com/webtorrent/bittorrent-tracker/issues/466)) ([3b2dedb](https://github.com/webtorrent/bittorrent-tracker/commit/3b2dedb4151615831ca12d3d0a830354b1c04e68)) + +## [10.0.5](https://github.com/webtorrent/bittorrent-tracker/compare/v10.0.4...v10.0.5) (2023-05-27) + + +### Bug Fixes + +* only stringify views ([#467](https://github.com/webtorrent/bittorrent-tracker/issues/467)) ([52f5502](https://github.com/webtorrent/bittorrent-tracker/commit/52f55020f38894e4d45e12c87184540d8b0acad3)) + +## [10.0.4](https://github.com/webtorrent/bittorrent-tracker/compare/v10.0.3...v10.0.4) (2023-05-26) + + +### Bug Fixes + +* drop buffer ([#465](https://github.com/webtorrent/bittorrent-tracker/issues/465)) ([c99eb89](https://github.com/webtorrent/bittorrent-tracker/commit/c99eb892088ef3c67ea5bf014dfdd86799251a7e)) + +## [10.0.3](https://github.com/webtorrent/bittorrent-tracker/compare/v10.0.2...v10.0.3) (2023-05-25) + + +### Performance Improvements + +* replace simple websocket with maintained one ([#464](https://github.com/webtorrent/bittorrent-tracker/issues/464)) ([3f01c29](https://github.com/webtorrent/bittorrent-tracker/commit/3f01c29122efd726d805673da82f43ce5592b793)) + +## [10.0.2](https://github.com/webtorrent/bittorrent-tracker/compare/v10.0.1...v10.0.2) (2023-02-01) + + +### Bug Fixes + +* **deps:** update dependency ws to v8 ([#448](https://github.com/webtorrent/bittorrent-tracker/issues/448)) ([2209d4f](https://github.com/webtorrent/bittorrent-tracker/commit/2209d4f21bdee10e575c1728c3accf7bd34380c9)), closes [#449](https://github.com/webtorrent/bittorrent-tracker/issues/449) + +## [10.0.1](https://github.com/webtorrent/bittorrent-tracker/compare/v10.0.0...v10.0.1) (2022-12-07) + + +### Bug Fixes + +* **deps:** update dependency bencode to v3 ([#434](https://github.com/webtorrent/bittorrent-tracker/issues/434)) [skip ci] ([926ceee](https://github.com/webtorrent/bittorrent-tracker/commit/926ceee0bac6dfe49877566aaa3cf645689492d1)) +* **deps:** update dependency string2compact to v2 ([#437](https://github.com/webtorrent/bittorrent-tracker/issues/437)) ([9be843c](https://github.com/webtorrent/bittorrent-tracker/commit/9be843c5e46ac2ab518187bf0d348e1e69e8633d)) + +# [10.0.0](https://github.com/webtorrent/bittorrent-tracker/compare/v9.19.0...v10.0.0) (2022-12-05) + + +### Features + +* esm ([#431](https://github.com/webtorrent/bittorrent-tracker/issues/431)) ([e6d3189](https://github.com/webtorrent/bittorrent-tracker/commit/e6d3189edf1a170197a799b97d84c632692b394f)) + + +### BREAKING CHANGES + +* ESM only + +* feat: esm + +* fix: linter oops + +# [9.19.0](https://github.com/webtorrent/bittorrent-tracker/compare/v9.18.6...v9.19.0) (2022-06-01) + + +### Features + +* **events:** Support of `paused` client event ([#411](https://github.com/webtorrent/bittorrent-tracker/issues/411)) ([ef76b3f](https://github.com/webtorrent/bittorrent-tracker/commit/ef76b3f3b6beee87f57d74addd0ca2ef2c517b6d)) + +## [9.18.6](https://github.com/webtorrent/bittorrent-tracker/compare/v9.18.5...v9.18.6) (2022-05-11) + + +### Bug Fixes + +* revert [#420](https://github.com/webtorrent/bittorrent-tracker/issues/420) ([8d54938](https://github.com/webtorrent/bittorrent-tracker/commit/8d54938f164347d57a7991268d191e44b752de7f)) + +## [9.18.5](https://github.com/webtorrent/bittorrent-tracker/compare/v9.18.4...v9.18.5) (2022-03-25) + + +### Bug Fixes + +* connection leaks ([#420](https://github.com/webtorrent/bittorrent-tracker/issues/420)) ([f7928cf](https://github.com/webtorrent/bittorrent-tracker/commit/f7928cfcc646cd95556549b64e61228892314682)) + +## [9.18.4](https://github.com/webtorrent/bittorrent-tracker/compare/v9.18.3...v9.18.4) (2022-03-06) + + +### Bug Fixes + +* typo in ws example ([#417](https://github.com/webtorrent/bittorrent-tracker/issues/417)) ([023afb9](https://github.com/webtorrent/bittorrent-tracker/commit/023afb9a3228d60392a18e70f85cdb6af5fa79fb)) + +## [9.18.3](https://github.com/webtorrent/bittorrent-tracker/compare/v9.18.2...v9.18.3) (2021-10-29) + + +### Bug Fixes + +* **deps:** update dependency clone to v2 ([#393](https://github.com/webtorrent/bittorrent-tracker/issues/393)) ([dc6f796](https://github.com/webtorrent/bittorrent-tracker/commit/dc6f7966844216c39491d6623dd412d5ca65d4c4)) + +## [9.18.2](https://github.com/webtorrent/bittorrent-tracker/compare/v9.18.1...v9.18.2) (2021-09-02) + + +### Bug Fixes + +* **deps:** update dependency socks to v2 ([#394](https://github.com/webtorrent/bittorrent-tracker/issues/394)) ([353e1f4](https://github.com/webtorrent/bittorrent-tracker/commit/353e1f40093a5e74cb54219abbae8ef0cc3d9e0b)) + +## [9.18.1](https://github.com/webtorrent/bittorrent-tracker/compare/v9.18.0...v9.18.1) (2021-09-01) + + +### Bug Fixes + +* disable socks in chromeapp ([#398](https://github.com/webtorrent/bittorrent-tracker/issues/398)) ([7fd5877](https://github.com/webtorrent/bittorrent-tracker/commit/7fd587789548453a852ea01e54900a5e9155db67)) + +# [9.18.0](https://github.com/webtorrent/bittorrent-tracker/compare/v9.17.4...v9.18.0) (2021-08-20) + + +### Features + +* add proxy support for tracker clients ([#356](https://github.com/webtorrent/bittorrent-tracker/issues/356)) ([ad64dc3](https://github.com/webtorrent/bittorrent-tracker/commit/ad64dc3a68cddccc2c1f05d0d8bb833f2c4860b2)) + +## [9.17.4](https://github.com/webtorrent/bittorrent-tracker/compare/v9.17.3...v9.17.4) (2021-07-22) + + +### Bug Fixes + +* if websocket closed, don't produce a response ([ca88435](https://github.com/webtorrent/bittorrent-tracker/commit/ca88435617e59714a456031c75b3a329897d97bd)) + +## [9.17.3](https://github.com/webtorrent/bittorrent-tracker/compare/v9.17.2...v9.17.3) (2021-07-02) + + +### Bug Fixes + +* auto update authors on version ([b5ffc70](https://github.com/webtorrent/bittorrent-tracker/commit/b5ffc708ada0bef66e7fa0cd1872527ea6dd8d53)) + +## [9.17.2](https://github.com/webtorrent/bittorrent-tracker/compare/v9.17.1...v9.17.2) (2021-06-15) + + +### Bug Fixes + +* modernize ([e5994d2](https://github.com/webtorrent/bittorrent-tracker/commit/e5994d2ebdec10fe2165e31f5b498382eeeaaf5f)) + +## [9.17.1](https://github.com/webtorrent/bittorrent-tracker/compare/v9.17.0...v9.17.1) (2021-06-15) + + +### Bug Fixes + +* add package-lock ([0e486b0](https://github.com/webtorrent/bittorrent-tracker/commit/0e486b09d80d30e1c13d4624e29c4251000d4092)) +* **deps:** update dependency bn.js to ^5.2.0 ([2d36e4a](https://github.com/webtorrent/bittorrent-tracker/commit/2d36e4ae60b1bac51773f2dca81c1a158b51cb28)) +* **deps:** update dependency chrome-dgram to ^3.0.6 ([a82aaaa](https://github.com/webtorrent/bittorrent-tracker/commit/a82aaaa31963a0d9adb640166f417142c5d7b970)) +* **deps:** update dependency run-parallel to ^1.2.0 ([fcf25ed](https://github.com/webtorrent/bittorrent-tracker/commit/fcf25ed40e1fd64e630b10a0281bc09604b901d3)) +* **deps:** update dependency run-series to ^1.1.9 ([fa2c33f](https://github.com/webtorrent/bittorrent-tracker/commit/fa2c33fc91f8ef0a47d0f40b7a046ae179ee328a)) +* **deps:** update dependency simple-websocket to ^9.1.0 ([96fedbd](https://github.com/webtorrent/bittorrent-tracker/commit/96fedbdf56ddcf6627eb373a33589db885cb4fb7)) +* **deps:** update dependency ws to ^7.4.5 ([6ad7ead](https://github.com/webtorrent/bittorrent-tracker/commit/6ad7ead994e5cb99980a406aea908e4b9ff6151c)) +* **deps:** update webtorrent ([1e8d47d](https://github.com/webtorrent/bittorrent-tracker/commit/1e8d47dcd8f5f53b42aa75265a129f950d16feef)) +* UDP url parsing ([8e24a8c](https://github.com/webtorrent/bittorrent-tracker/commit/8e24a8c97b55bbaaf2c92a496d1cd30b0c008934)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..e5fc5a241 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing Guidelines + +Contributions welcome! + +**Before spending lots of time on something, ask for feedback on your idea first!** + +Please search issues and pull requests before adding something new to avoid duplicating +efforts and conversations. + +This project welcomes non-code contributions, too! The following types of contributions +are welcome: + +- **Ideas**: participate in an issue thread or start your own to have your voice heard. +- **Writing**: contribute your expertise in an area by helping expand the included docs. +- **Copy editing**: fix typos, clarify language, and improve the quality of the docs. +- **Formatting**: help keep docs easy to read with consistent formatting. + +## Code Style + +[![JavaScript Style Guide][standard-image]][standard-url] + +This repository uses [`standard`][standard-url] to maintain code style and consistency, +and to avoid style arguments. `npm test` runs `standard` automatically, so you don't have +to! + +[standard-image]: https://cdn.rawgit.com/feross/standard/master/badge.svg +[standard-url]: https://standardjs.com + +## Project Governance + +Individuals making significant and valuable contributions are given commit-access to the +project to contribute as they see fit. This project is more like an open wiki than a +standard guarded open source project. + +### Rules + +There are a few basic ground-rules for contributors: + +1. **No `--force` pushes** or modifying the Git history in any way. +2. **Non-master branches** should be used for ongoing work. +3. **Significant modifications** like API changes should be subject to a **pull request** + to solicit feedback from other contributors. +4. **Pull requests** are *encouraged* for all contributions to solicit feedback, but left to + the discretion of the contributor. + +### Releases + +Declaring formal releases remains the prerogative of the project maintainer. + +### Changes to this arrangement + +This is an experiment and feedback is welcome! This document may also be subject to pull- +requests or changes by contributors where you believe you have something valuable to add +or change. + +## Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +- (a) The contribution was created in whole or in part by me and I have the right to + submit it under the open source license indicated in the file; or + +- (b) The contribution is based upon previous work that, to the best of my knowledge, is + covered under an appropriate open source license and I have the right under that license + to submit that work with modifications, whether created in whole or in part by me, under + the same open source license (unless I am permitted to submit under a different + license), as indicated in the file; or + +- (c) The contribution was provided directly to me by some other person who certified + (a), (b) or (c) and I have not modified it. + +- (d) I understand and agree that this project and the contribution are public and that a + record of the contribution (including all personal information I submit with it, + including my sign-off) is maintained indefinitely and may be redistributed consistent + with this project or the open source license(s) involved. diff --git a/LICENSE b/LICENSE index c7e685275..70a7089c6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) Feross Aboukhadijeh +Copyright (c) Feross Aboukhadijeh and WebTorrent, LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/README.md b/README.md index c8f188e91..db0eba161 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,46 @@ -# bittorrent-tracker [![travis][travis-image]][travis-url] [![npm][npm-image]][npm-url] [![downloads][downloads-image]][downloads-url] +# bittorrent-tracker [![ci][ci-image]][ci-url] [![npm][npm-image]][npm-url] [![downloads][downloads-image]][downloads-url] [![javascript style guide][standard-image]][standard-url] -[travis-image]: https://img.shields.io/travis/feross/bittorrent-tracker.svg?style=flat -[travis-url]: https://travis-ci.org/feross/bittorrent-tracker -[npm-image]: https://img.shields.io/npm/v/bittorrent-tracker.svg?style=flat +[ci-image]: https://img.shields.io/github/actions/workflow/status/webtorrent/bittorrent-tracker/ci.yml +[ci-url]: https://github.com/webtorrent/bittorrent-tracker/actions +[npm-image]: https://img.shields.io/npm/v/bittorrent-tracker.svg [npm-url]: https://npmjs.org/package/bittorrent-tracker -[downloads-image]: https://img.shields.io/npm/dm/bittorrent-tracker.svg?style=flat +[downloads-image]: https://img.shields.io/npm/dm/bittorrent-tracker.svg [downloads-url]: https://npmjs.org/package/bittorrent-tracker +[standard-image]: https://img.shields.io/badge/code_style-standard-brightgreen.svg +[standard-url]: https://standardjs.com #### Simple, robust, BitTorrent tracker (client & server) implementation -![tracker](https://raw.githubusercontent.com/feross/bittorrent-tracker/master/img.png) +![tracker visualization](img/img.png) Node.js implementation of a [BitTorrent tracker](https://wiki.theory.org/BitTorrentSpecification#Tracker_HTTP.2FHTTPS_Protocol), client and server. -A **BitTorrent tracker** is an HTTP service which responds to GET requests from BitTorrent +A **BitTorrent tracker** is a web service which responds to requests from BitTorrent clients. The requests include metrics from clients that help the tracker keep overall statistics about the torrent. The response includes a peer list that helps the client -participate in the torrent. +participate in the torrent swarm. This module is used by [WebTorrent](http://webtorrent.io). ## features -- includes client & server implementations -- supports HTTP & UDP trackers ([BEP 15](http://www.bittorrent.org/beps/bep_0015.html)) -- supports tracker "scrape" extension -- robust and well-tested (comprehensive test suite, and used by [WebTorrent](http://webtorrent.io) and [peerflix](https://github.com/mafintosh/peerflix)) -- supports ipv4 & ipv6 +- Includes client & server implementations +- Supports all mainstream tracker types: + - HTTP trackers + - UDP trackers ([BEP 15](http://www.bittorrent.org/beps/bep_0015.html)) + - WebTorrent trackers ([BEP forthcoming](http://webtorrent.io)) +- Supports ipv4 & ipv6 +- Supports tracker "scrape" extension +- Robust and well-tested + - Comprehensive test suite (runs entirely offline, so it's reliable) + - Used by popular clients: [WebTorrent](http://webtorrent.io), [peerflix](https://www.npmjs.com/package/peerflix), and [playback](https://mafintosh.github.io/playback/) +- Tracker statistics available via web interface at `/stats` or JSON data at `/stats.json` -Also see [bittorrent-dht](https://github.com/feross/bittorrent-dht). +Also see [bittorrent-dht](https://www.npmjs.com/package/bittorrent-dht). + +### Tracker stats + +![Screenshot](img/trackerStats.png) ## install @@ -43,17 +55,50 @@ npm install bittorrent-tracker To connect to a tracker, just do this: ```js -var Client = require('bittorrent-tracker') -var parseTorrent = require('parse-torrent') -var fs = require('fs') - -var torrent = fs.readFileSync(__dirname + '/torrents/bitlove-intro.torrent') -var parsedTorrent = parseTorrent(torrent) // { infoHash: 'xxx', length: xx, announce: ['xx', 'xx'] } - -var peerId = new Buffer('01234567890123456789') -var port = 6881 - -var client = new Client(peerId, port, parsedTorrent) +import Client from 'bittorrent-tracker' + +const requiredOpts = { + infoHash: new Buffer('012345678901234567890'), // hex string or Buffer + peerId: new Buffer('01234567890123456789'), // hex string or Buffer + announce: [], // list of tracker server urls + port: 6881 // torrent client port, (in browser, optional) +} + +const optionalOpts = { + // RTCPeerConnection config object (only used in browser) + rtcConfig: {}, + // User-Agent header for http requests + userAgent: '', + // Custom webrtc impl, useful in node to specify [wrtc](https://npmjs.com/package/wrtc) + wrtc: {}, + getAnnounceOpts: function () { + // Provide a callback that will be called whenever announce() is called + // internally (on timer), or by the user + return { + uploaded: 0, + downloaded: 0, + left: 0, + customParam: 'blah' // custom parameters supported + } + }, + // Proxy options (used to proxy requests in node) + proxyOpts: { + // For WSS trackers this is always a http.Agent + // For UDP trackers this is an object of options for the Socks Connection + // For HTTP trackers this is either an undici Agent if using Node16 or later, or http.Agent if using versions prior to Node 16, ex: + // import Socks from 'socks' + // proxyOpts.socksProxy = new Socks.Agent(optionsObject, isHttps) + // or if using Node 16 or later + // import { socksDispatcher } from 'fetch-socks' + // proxyOpts.socksProxy = socksDispatcher(optionsObject) + socksProxy: new SocksProxy(socksOptionsObject), + // Populated with socksProxy if it's provided + httpAgent: new http.Agent(agentOptionsObject), + httpsAgent: new https.Agent(agentOptionsObject) + }, +} + +const client = new Client(requiredOpts) client.on('error', function (err) { // fatal client error! @@ -84,6 +129,14 @@ client.complete() // force a tracker announce. will trigger more 'update' events and maybe more 'peer' events client.update() +// provide parameters to the tracker +client.update({ + uploaded: 0, + downloaded: 0, + left: 0, + customParam: 'blah' // custom parameters supported +}) + // stop getting peers from the tracker, gracefully leave the swarm client.stop() @@ -97,7 +150,7 @@ client.on('scrape', function (data) { console.log('got a scrape response from tracker: ' + data.announce) console.log('number of seeders in the swarm: ' + data.complete) console.log('number of leechers in the swarm: ' + data.incomplete) - console.log('number of total downloads of this torrent: ' + data.incomplete) + console.log('number of total downloads of this torrent: ' + data.downloaded) }) ``` @@ -106,12 +159,15 @@ client.on('scrape', function (data) { To start a BitTorrent tracker server to track swarms of peers: ```js -var Server = require('bittorrent-tracker').Server +import { Server } from 'bittorrent-tracker' +// Or import Server from 'bittorrent-tracker/server' -var server = new Server({ +const server = new Server({ udp: true, // enable udp server? [default=true] http: true, // enable http server? [default=true] - ws: true, // enable websocket server? [default=false] + ws: true, // enable websocket server? [default=true] + stats: true, // enable web-based statistics? [default=true] + trustProxy: false, // enable trusting x-forwarded-for header for remote IP [default=false] filter: function (infoHash, params, cb) { // Blacklist/whitelist function for allowing/disallowing torrents. If this option is // omitted, all torrents are allowed. It is possible to interface with a database or @@ -119,16 +175,20 @@ var server = new Server({ // It is possible to block by peer id (whitelisting torrent clients) or by secret // key (private trackers). Full access to the original HTTP/UDP request parameters - // are available n `params`. + // are available in `params`. // This example only allows one torrent. - var allowed = (infoHash === 'aaa67059ed6bd08362da625b3ae77f6f4a075aaa') - cb(allowed) - - // In addition to returning a boolean (`true` for allowed, `false` for disallowed), - // you can return an `Error` object to disallow and provide a custom reason. - }) + const allowed = (infoHash === 'aaa67059ed6bd08362da625b3ae77f6f4a075aaa') + if (allowed) { + // If the callback is passed `null`, the torrent will be allowed. + cb(null) + } else { + // If the callback is passed an `Error` object, the torrent will be disallowed + // and the error's `message` property will be given as the reason. + cb(new Error('disallowed torrent')) + } + } }) // Internal http, udp, and websocket servers exposed as public properties. @@ -148,12 +208,34 @@ server.on('warning', function (err) { server.on('listening', function () { // fired when all requested servers are listening - console.log('listening on http port:' + server.http.address().port) - console.log('listening on udp port:' + server.udp.address().port) + + // HTTP + const httpAddr = server.http.address() + const httpHost = httpAddr.address !== '::' ? httpAddr.address : 'localhost' + const httpPort = httpAddr.port + console.log(`HTTP tracker: http://${httpHost}:${httpPort}/announce`) + + // UDP + const udpAddr = server.udp.address() + const udpHost = udpAddr.address + const udpPort = udpAddr.port + console.log(`UDP tracker: udp://${udpHost}:${udpPort}`) + + // WS + const wsAddr = server.ws.address() + const wsHost = wsAddr.address !== '::' ? wsAddr.address : 'localhost' + const wsPort = wsAddr.port + console.log(`WebSocket tracker: ws://${wsHost}:${wsPort}`) + }) + // start tracker server listening! Use 0 to listen on a random free port. -server.listen(port, hostname, onlistening) +const port = 0 +const hostname = "localhost" +server.listen(port, hostname, () => { + // Do something on listening... +}) // listen for individual tracker messages from peers: @@ -180,8 +262,33 @@ server.torrents[infoHash].peers The http server will handle requests for the following paths: `/announce`, `/scrape`. Requests for other paths will not be handled. +## multi scrape + +Scraping multiple torrent info is possible with a static `Client.scrape` method: + +```js +import Client from 'bittorrent-tracker' +// Or import Client from 'bittorrent-tracker/client' + +Client.scrape({ announce: announceUrl, infoHash: [ infoHash1, infoHash2 ]}, function (err, results) { + results[infoHash1].announce + results[infoHash1].infoHash + results[infoHash1].complete + results[infoHash1].incomplete + results[infoHash1].downloaded + + // ... +}) +```` + ## command line +Install `bittorrent-tracker` globally: + +```sh +$ npm install -g bittorrent-tracker +``` + Easily start a tracker server: ```sh @@ -198,22 +305,22 @@ $ bittorrent-tracker --help bittorrent-tracker - Start a bittorrent tracker server Usage: - bittorrent-tracker + bittorrent-tracker [OPTIONS] + + If no --http, --udp, or --ws option is supplied, all tracker types will be started. Options: - -p, --port [number] change the port [default: 8000] - --trust-proxy trust 'x-forwarded-for' header from reverse proxy - --interval tell clients to announce on this interval (ms) - --http enable http server [default: true] - --udp enable udp server [default: true] - --ws enable ws server [default: false] - -q, --quiet only show error output - -s, --silent show no output - -v, --version print the current version - - Please report bugs! https://github.com/feross/bittorrent-tracker/issues + -p, --port [number] change the port [default: 8000] + --trust-proxy trust 'x-forwarded-for' header from reverse proxy + --interval client announce interval (ms) [default: 600000] + --http enable http server + --udp enable udp server + --ws enable websocket server + -q, --quiet only show error output + -s, --silent show no output + -v, --version print the current version ``` ## license -MIT. Copyright (c) [Feross Aboukhadijeh](http://feross.org). +MIT. Copyright (c) [Feross Aboukhadijeh](https://feross.org) and [WebTorrent, LLC](https://webtorrent.io). diff --git a/bin/cmd.js b/bin/cmd.js index 0fd15e592..c57eef55b 100755 --- a/bin/cmd.js +++ b/bin/cmd.js @@ -1,9 +1,9 @@ #!/usr/bin/env node -var minimist = require('minimist') -var Server = require('../').Server +import minimist from 'minimist' +import { Server } from '../index.js' -var argv = minimist(process.argv.slice(2), { +const argv = minimist(process.argv.slice(2), { alias: { h: 'help', p: 'port', @@ -19,10 +19,17 @@ var argv = minimist(process.argv.slice(2), { 'trust-proxy', 'udp', 'version', - 'ws' + 'ws', + 'stats' + ], + string: [ + 'http-hostname', + 'udp-hostname', + 'udp6-hostname' ], default: { - port: 8000 + port: 8000, + stats: true } }) @@ -32,7 +39,7 @@ if (argv.version) { } if (argv.help) { - console.log(function () { + console.log((() => { /* bittorrent-tracker - Start a bittorrent tracker server @@ -42,66 +49,96 @@ if (argv.help) { If no --http, --udp, or --ws option is supplied, all tracker types will be started. Options: - -p, --port [number] change the port [default: 8000] - --trust-proxy trust 'x-forwarded-for' header from reverse proxy - --interval client announce interval (ms) [default: 600000] - --http enable http server - --udp enable udp server - --ws enable websocket server - -q, --quiet only show error output - -s, --silent show no output - -v, --version print the current version - - Please report bugs! https://github.com/feross/bittorrent-tracker/issues + -p, --port [number] change the port [default: 8000] + --http-hostname [string] change the http server hostname [default: '::'] + --udp-hostname [string] change the udp hostname [default: '0.0.0.0'] + --udp6-hostname [string] change the udp6 hostname [default: '::'] + --trust-proxy trust 'x-forwarded-for' header from reverse proxy + --interval client announce interval (ms) [default: 600000] + --http enable http server + --udp enable udp server + --ws enable websocket server + --stats enable web-based statistics (default: true) + -q, --quiet only show error output + -s, --silent show no output + -v, --version print the current version */ - }.toString().split(/\n/).slice(2, -2).join('\n')) + }).toString().split(/\n/).slice(2, -2).join('\n')) process.exit(0) } if (argv.silent) argv.quiet = true -var allFalsy = !argv.http && !argv.udp && !argv.ws +const allFalsy = !argv.http && !argv.udp && !argv.ws argv.http = allFalsy || argv.http argv.udp = allFalsy || argv.udp argv.ws = allFalsy || argv.ws -var server = new Server({ +const server = new Server({ http: argv.http, interval: argv.interval, + stats: argv.stats, trustProxy: argv['trust-proxy'], udp: argv.udp, ws: argv.ws }) -server.on('error', function (err) { - if (!argv.silent) console.error('ERROR: ' + err.message) +server.on('error', err => { + if (!argv.silent) console.error(`${new Date().toISOString()} ERROR: ${err.message}`) }) -server.on('warning', function (err) { - if (!argv.quiet) console.log('WARNING: ' + err.message) +server.on('warning', err => { + if (!argv.quiet) console.log(`${new Date().toISOString()} WARNING: ${err.message}`) }) -server.on('update', function (addr) { - if (!argv.quiet) console.log('update: ' + addr) +server.on('update', addr => { + if (!argv.quiet) console.log(`${new Date().toISOString()} update: ${addr}`) }) -server.on('complete', function (addr) { - if (!argv.quiet) console.log('complete: ' + addr) +server.on('complete', addr => { + if (!argv.quiet) console.log(`${new Date().toISOString()} complete: ${addr}`) }) -server.on('start', function (addr) { - if (!argv.quiet) console.log('start: ' + addr) +server.on('start', addr => { + if (!argv.quiet) console.log(`${new Date().toISOString()} start: ${addr}`) }) -server.on('stop', function (addr) { - if (!argv.quiet) console.log('stop: ' + addr) +server.on('stop', addr => { + if (!argv.quiet) console.log(`${new Date().toISOString()} stop: ${addr}`) }) -server.listen(argv.port, function () { +const hostname = { + http: argv['http-hostname'], + udp4: argv['udp-hostname'], + udp6: argv['udp6-hostname'] +} + +server.listen(argv.port, hostname, () => { if (server.http && argv.http && !argv.quiet) { - console.log('HTTP tracker: http://localhost:' + server.http.address().port + '/announce') + const httpAddr = server.http.address() + const httpHost = httpAddr.address !== '::' ? httpAddr.address : 'localhost' + const httpPort = httpAddr.port + console.log(`${new Date().toISOString()} HTTP tracker: http://${httpHost}:${httpPort}/announce`) } if (server.udp && !argv.quiet) { - console.log('UDP tracker: udp://localhost:' + server.udp.address().port) + const udpAddr = server.udp.address() + const udpHost = udpAddr.address + const udpPort = udpAddr.port + console.log(`${new Date().toISOString()} UDP tracker: udp://${udpHost}:${udpPort}`) + } + if (server.udp6 && !argv.quiet) { + const udp6Addr = server.udp6.address() + const udp6Host = udp6Addr.address !== '::' ? udp6Addr.address : 'localhost' + const udp6Port = udp6Addr.port + console.log(`${new Date().toISOString()} UDP6 tracker: udp://${udp6Host}:${udp6Port}`) } if (server.ws && !argv.quiet) { - console.log('WebSocket tracker: ws://localhost:' + server.http.address().port) + const wsAddr = server.http.address() + const wsHost = wsAddr.address !== '::' ? wsAddr.address : 'localhost' + const wsPort = wsAddr.port + console.log(`${new Date().toISOString()} WebSocket tracker: ws://${wsHost}:${wsPort}`) + } + if (server.http && argv.stats && !argv.quiet) { + const statsAddr = server.http.address() + const statsHost = statsAddr.address !== '::' ? statsAddr.address : 'localhost' + const statsPort = statsAddr.port + console.log(`${new Date().toISOString()} Tracker stats: http://${statsHost}:${statsPort}/stats`) } }) diff --git a/client.js b/client.js index 82a6a1450..c37345f17 100644 --- a/client.js +++ b/client.js @@ -1,126 +1,282 @@ -module.exports = Client +import Debug from 'debug' +import EventEmitter from 'events' +import once from 'once' +import parallel from 'run-parallel' +import Peer from '@thaunknown/simple-peer/lite.js' +import queueMicrotask from 'queue-microtask' +import { hex2arr, hex2bin, text2arr, arr2hex, arr2text } from 'uint8-util' -var debug = require('debug')('bittorrent-tracker') -var EventEmitter = require('events').EventEmitter -var inherits = require('inherits') -var once = require('once') -var parallel = require('run-parallel') -var url = require('url') +import common from './lib/common.js' +import HTTPTracker from './lib/client/http-tracker.js' // empty object in browser +import UDPTracker from './lib/client/udp-tracker.js' // empty object in browser +import WebSocketTracker from './lib/client/websocket-tracker.js' -var common = require('./lib/common') -var HTTPTracker = require('./lib/http-tracker') // empty object in browser -var UDPTracker = require('./lib/udp-tracker') // empty object in browser -var WebSocketTracker = require('./lib/websocket-tracker') - -inherits(Client, EventEmitter) +const debug = Debug('bittorrent-tracker:client') /** * BitTorrent tracker client. * * Find torrent peers, to help a torrent client participate in a torrent swarm. * - * @param {string|Buffer} peerId peer id - * @param {Number} port torrent client listening port - * @param {Object} torrent parsed torrent - * @param {Object} opts options object - * @param {Number} opts.numWant number of peers to request - * @param {Number} opts.interval announce interval (in ms) - * @param {Number} opts.rtcConfig RTCPeerConnection configuration object - * @param {Number} opts.wrtc custom webrtc implementation + * @param {Object} opts options object + * @param {string|Uint8Array} opts.infoHash torrent info hash + * @param {string|Uint8Array} opts.peerId peer id + * @param {string|Array.} opts.announce announce + * @param {number} opts.port torrent client listening port + * @param {function} opts.getAnnounceOpts callback to provide data to tracker + * @param {number} opts.rtcConfig RTCPeerConnection configuration object + * @param {number} opts.userAgent User-Agent header for http requests + * @param {number} opts.wrtc custom webrtc impl (useful in node.js) + * @param {object} opts.proxyOpts proxy options (useful in node.js) */ -function Client (peerId, port, torrent, opts) { - var self = this - if (!(self instanceof Client)) return new Client(peerId, port, torrent, opts) - EventEmitter.call(self) - if (!opts) opts = {} - - // required - self._peerId = Buffer.isBuffer(peerId) - ? peerId - : new Buffer(peerId, 'hex') - self._peerIdHex = self._peerId.toString('hex') - self._peerIdBinary = self._peerId.toString('binary') - - self._infoHash = Buffer.isBuffer(torrent.infoHash) - ? torrent.infoHash - : new Buffer(torrent.infoHash, 'hex') - self._infoHashHex = self._infoHash.toString('hex') - self._infoHashBinary = self._infoHash.toString('binary') - - self.torrentLength = torrent.length - self.destroyed = false - - self._port = port - - self._rtcConfig = opts.rtcConfig - self._wrtc = opts.wrtc - - // optional - self._numWant = opts.numWant || common.DEFAULT_ANNOUNCE_PEERS - self._intervalMs = opts.interval || common.DEFAULT_ANNOUNCE_INTERVAL - - debug('new client %s', self._infoHashHex) - - var trackerOpts = { interval: self._intervalMs } - var webrtcSupport = !!self._wrtc || typeof window !== 'undefined' - - var announce = (typeof torrent.announce === 'string') - ? [ torrent.announce ] - : torrent.announce == null - ? [] - : torrent.announce - - self._trackers = announce - .map(function (announceUrl) { - announceUrl = announceUrl.toString() - var protocol = url.parse(announceUrl).protocol - - if ((protocol === 'http:' || protocol === 'https:') && - typeof HTTPTracker === 'function') { - return new HTTPTracker(self, announceUrl, trackerOpts) - } else if (protocol === 'udp:' && typeof UDPTracker === 'function') { - return new UDPTracker(self, announceUrl, trackerOpts) - } else if ((protocol === 'ws:' || protocol === 'wss:') && webrtcSupport) { - return new WebSocketTracker(self, announceUrl, trackerOpts) - } else { - process.nextTick(function () { - var err = new Error('unsupported tracker protocol for ' + announceUrl) - self.emit('warning', err) - }) +class Client extends EventEmitter { + constructor (opts = {}) { + super() + + if (!opts.peerId) throw new Error('Option `peerId` is required') + if (!opts.infoHash) throw new Error('Option `infoHash` is required') + if (!opts.announce) throw new Error('Option `announce` is required') + if (!process.browser && !opts.port) throw new Error('Option `port` is required') + + this.peerId = typeof opts.peerId === 'string' + ? opts.peerId + : arr2hex(opts.peerId) + this._peerIdBuffer = hex2arr(this.peerId) + this._peerIdBinary = hex2bin(this.peerId) + + this.infoHash = typeof opts.infoHash === 'string' + ? opts.infoHash.toLowerCase() + : arr2hex(opts.infoHash) + this._infoHashBuffer = hex2arr(this.infoHash) + this._infoHashBinary = hex2bin(this.infoHash) + + debug('new client %s', this.infoHash) + + this.destroyed = false + + this._port = opts.port + this._getAnnounceOpts = opts.getAnnounceOpts + this._rtcConfig = opts.rtcConfig + this._userAgent = opts.userAgent + this._proxyOpts = opts.proxyOpts + + // Support lazy 'wrtc' module initialization + // See: https://github.com/webtorrent/webtorrent-hybrid/issues/46 + this._wrtc = typeof opts.wrtc === 'function' ? opts.wrtc() : opts.wrtc + + let announce = typeof opts.announce === 'string' + ? [opts.announce] + : opts.announce == null ? [] : opts.announce + + // Remove trailing slash from trackers to catch duplicates + announce = announce.map(announceUrl => { + if (ArrayBuffer.isView(announceUrl)) announceUrl = arr2text(announceUrl) + if (announceUrl[announceUrl.length - 1] === '/') { + announceUrl = announceUrl.substring(0, announceUrl.length - 1) } - return null + return announceUrl + }) + // remove duplicates by converting to Set and back + announce = Array.from(new Set(announce)) + + const webrtcSupport = this._wrtc !== false && (!!this._wrtc || Peer.WEBRTC_SUPPORT) + + const nextTickWarn = err => { + queueMicrotask(() => { + this.emit('warning', err) + }) + } + + this._trackers = announce + .map(announceUrl => { + let parsedUrl + try { + parsedUrl = common.parseUrl(announceUrl) + } catch (err) { + nextTickWarn(new Error(`Invalid tracker URL: ${announceUrl}`)) + return null + } + + const port = parsedUrl.port + if (port < 0 || port > 65535) { + nextTickWarn(new Error(`Invalid tracker port: ${announceUrl}`)) + return null + } + + const protocol = parsedUrl.protocol + if ((protocol === 'http:' || protocol === 'https:') && + typeof HTTPTracker === 'function') { + return new HTTPTracker(this, announceUrl) + } else if (protocol === 'udp:' && typeof UDPTracker === 'function') { + return new UDPTracker(this, announceUrl) + } else if ((protocol === 'ws:' || protocol === 'wss:') && webrtcSupport) { + // Skip ws:// trackers on https:// sites because they throw SecurityError + if (protocol === 'ws:' && typeof window !== 'undefined' && + window.location.protocol === 'https:') { + nextTickWarn(new Error(`Unsupported tracker protocol: ${announceUrl}`)) + return null + } + return new WebSocketTracker(this, announceUrl) + } else { + nextTickWarn(new Error(`Unsupported tracker protocol: ${announceUrl}`)) + return null + } + }) + .filter(Boolean) + } + + /** + * Send a `start` announce to the trackers. + * @param {Object} opts + * @param {number=} opts.uploaded + * @param {number=} opts.downloaded + * @param {number=} opts.left (if not set, calculated automatically) + */ + start (opts) { + opts = this._defaultAnnounceOpts(opts) + opts.event = 'started' + debug('send `start` %o', opts) + this._announce(opts) + + // start announcing on intervals + this._trackers.forEach(tracker => { + tracker.setInterval() + }) + } + + /** + * Send a `stop` announce to the trackers. + * @param {Object} opts + * @param {number=} opts.uploaded + * @param {number=} opts.downloaded + * @param {number=} opts.numwant + * @param {number=} opts.left (if not set, calculated automatically) + */ + stop (opts) { + opts = this._defaultAnnounceOpts(opts) + opts.event = 'stopped' + debug('send `stop` %o', opts) + this._announce(opts) + } + + /** + * Send a `complete` announce to the trackers. + * @param {Object} opts + * @param {number=} opts.uploaded + * @param {number=} opts.downloaded + * @param {number=} opts.numwant + * @param {number=} opts.left (if not set, calculated automatically) + */ + complete (opts) { + if (!opts) opts = {} + opts = this._defaultAnnounceOpts(opts) + opts.event = 'completed' + debug('send `complete` %o', opts) + this._announce(opts) + } + + /** + * Send a `update` announce to the trackers. + * @param {Object} opts + * @param {number=} opts.uploaded + * @param {number=} opts.downloaded + * @param {number=} opts.numwant + * @param {number=} opts.left (if not set, calculated automatically) + */ + update (opts) { + opts = this._defaultAnnounceOpts(opts) + if (opts.event) delete opts.event + debug('send `update` %o', opts) + this._announce(opts) + } + + _announce (opts) { + this._trackers.forEach(tracker => { + // tracker should not modify `opts` object, it's passed to all trackers + tracker.announce(opts) + }) + } + + /** + * Send a scrape request to the trackers. + * @param {Object} opts + */ + scrape (opts) { + debug('send `scrape`') + if (!opts) opts = {} + this._trackers.forEach(tracker => { + // tracker should not modify `opts` object, it's passed to all trackers + tracker.scrape(opts) }) - .filter(Boolean) + } + + setInterval (intervalMs) { + debug('setInterval %d', intervalMs) + this._trackers.forEach(tracker => { + tracker.setInterval(intervalMs) + }) + } + + destroy (cb) { + if (this.destroyed) return + this.destroyed = true + debug('destroy') + + const tasks = this._trackers.map(tracker => cb => { + tracker.destroy(cb) + }) + + parallel(tasks, cb) + + this._trackers = [] + this._getAnnounceOpts = null + } + + _defaultAnnounceOpts (opts = {}) { + if (opts.numwant == null) opts.numwant = common.DEFAULT_ANNOUNCE_PEERS + + if (opts.uploaded == null) opts.uploaded = 0 + if (opts.downloaded == null) opts.downloaded = 0 + + if (this._getAnnounceOpts) opts = Object.assign({}, opts, this._getAnnounceOpts()) + + return opts + } } /** * Simple convenience function to scrape a tracker for an info hash without needing to * create a Client, pass it a parsed torrent, etc. Support scraping a tracker for multiple * torrents at the same time. - * @param {string} announceUrl - * @param {string|Array.} infoHash + * @params {Object} opts + * @param {string|Array.} opts.infoHash + * @param {string} opts.announce * @param {function} cb */ -Client.scrape = function (announceUrl, infoHash, cb) { +Client.scrape = (opts, cb) => { cb = once(cb) - var peerId = new Buffer('01234567890123456789') // dummy value - var port = 6881 // dummy value - var torrent = { - infoHash: Array.isArray(infoHash) ? infoHash[0] : infoHash, - announce: [ announceUrl ] - } - var client = new Client(peerId, port, torrent) + if (!opts.infoHash) throw new Error('Option `infoHash` is required') + if (!opts.announce) throw new Error('Option `announce` is required') + + const clientOpts = Object.assign({}, opts, { + infoHash: Array.isArray(opts.infoHash) ? opts.infoHash[0] : opts.infoHash, + peerId: text2arr('01234567890123456789'), // dummy value + port: 6881 // dummy value + }) + + const client = new Client(clientOpts) client.once('error', cb) + client.once('warning', cb) - var len = Array.isArray(infoHash) ? infoHash.length : 1 - var results = {} - client.on('scrape', function (data) { + let len = Array.isArray(opts.infoHash) ? opts.infoHash.length : 1 + const results = {} + client.on('scrape', data => { len -= 1 results[data.infoHash] = data if (len === 0) { client.destroy() - var keys = Object.keys(results) + const keys = Object.keys(results) if (keys.length === 1) { cb(null, results[keys[0]]) } else { @@ -129,142 +285,8 @@ Client.scrape = function (announceUrl, infoHash, cb) { } }) - infoHash = Array.isArray(infoHash) - ? infoHash.map(function (infoHash) { return new Buffer(infoHash, 'hex') }) - : new Buffer(infoHash, 'hex') - client.scrape({ infoHash: infoHash }) + client.scrape({ infoHash: opts.infoHash }) + return client } -/** - * Send a `start` announce to the trackers. - * @param {Object} opts - * @param {number=} opts.uploaded - * @param {number=} opts.downloaded - * @param {number=} opts.left (if not set, calculated automatically) - */ -Client.prototype.start = function (opts) { - var self = this - debug('send `start`') - opts = self._defaultAnnounceOpts(opts) - opts.event = 'started' - self._announce(opts) - - // start announcing on intervals - self._trackers.forEach(function (tracker) { - tracker.setInterval(self._intervalMs) - }) -} - -/** - * Send a `stop` announce to the trackers. - * @param {Object} opts - * @param {number=} opts.uploaded - * @param {number=} opts.downloaded - * @param {number=} opts.left (if not set, calculated automatically) - */ -Client.prototype.stop = function (opts) { - var self = this - debug('send `stop`') - opts = self._defaultAnnounceOpts(opts) - opts.event = 'stopped' - self._announce(opts) -} - -/** - * Send a `complete` announce to the trackers. - * @param {Object} opts - * @param {number=} opts.uploaded - * @param {number=} opts.downloaded - * @param {number=} opts.left (if not set, calculated automatically) - */ -Client.prototype.complete = function (opts) { - var self = this - debug('send `complete`') - if (!opts) opts = {} - if (opts.downloaded == null && self.torrentLength != null) { - opts.downloaded = self.torrentLength - } - opts = self._defaultAnnounceOpts(opts) - opts.event = 'completed' - self._announce(opts) -} - -/** - * Send a `update` announce to the trackers. - * @param {Object} opts - * @param {number=} opts.uploaded - * @param {number=} opts.downloaded - * @param {number=} opts.left (if not set, calculated automatically) - */ -Client.prototype.update = function (opts) { - var self = this - debug('send `update`') - opts = self._defaultAnnounceOpts(opts) - if (opts.event) delete opts.event - self._announce(opts) -} - -Client.prototype._announce = function (opts) { - var self = this - self._trackers.forEach(function (tracker) { - tracker.announce(opts) - }) -} - -/** - * Send a scrape request to the trackers. - * @param {Object} opts - * @param {number=} opts.uploaded - * @param {number=} opts.downloaded - * @param {number=} opts.left (if not set, calculated automatically) - */ -Client.prototype.scrape = function (opts) { - var self = this - debug('send `scrape`') - if (!opts) opts = {} - self._trackers.forEach(function (tracker) { - tracker.scrape(opts) - }) -} - -Client.prototype.setInterval = function (intervalMs) { - var self = this - debug('setInterval') - self._intervalMs = intervalMs - - self._trackers.forEach(function (tracker) { - tracker.setInterval(intervalMs) - }) -} - -Client.prototype.destroy = function (cb) { - var self = this - if (self.destroyed) return - self.destroyed = true - debug('destroy') - - var tasks = self._trackers.map(function (tracker) { - return function (cb) { - tracker.destroy(cb) - tracker.setInterval(0) // stop announcing on intervals - } - }) - - parallel(tasks, cb) - self._trackers = [] -} - -Client.prototype._defaultAnnounceOpts = function (opts) { - var self = this - if (!opts) opts = {} - - if (opts.numWant == null) opts.numWant = self._numWant - - if (opts.uploaded == null) opts.uploaded = 0 - if (opts.downloaded == null) opts.downloaded = 0 - - if (opts.left == null && self.torrentLength != null) { - opts.left = self.torrentLength - opts.downloaded - } - return opts -} +export default Client diff --git a/examples/express-embed/server.js b/examples/express-embed/server.js index 496ad2135..947dcbfbe 100755 --- a/examples/express-embed/server.js +++ b/examples/express-embed/server.js @@ -1,26 +1,27 @@ #!/usr/bin/env node -var Server = require('../..').Server -var express = require('express') -var app = express() +import { Server } from '../../index.js' +import express from 'express' +const app = express() // https://wiki.theory.org/BitTorrentSpecification#peer_id -var whitelist = { +const whitelist = { UT: true // uTorrent } -var server = new Server({ +const server = new Server({ http: false, // we do our own - udp: false, // not interested - filter: function (params) { + udp: false, // not interested + ws: false, // not interested + filter (params) { // black/whitelist for disallowing/allowing specific clients [default=allow all] // this example only allows the uTorrent client - var client = params.peer_id[1] + params.peer_id[2] + const client = params.peer_id[1] + params.peer_id[2] return whitelist[client] } }) -var onHttpRequest = server.onHttpRequest.bind(server) +const onHttpRequest = server.onHttpRequest.bind(server) app.get('/announce', onHttpRequest) app.get('/scrape', onHttpRequest) diff --git a/examples/tracker-scrape.md b/examples/tracker-scrape.md new file mode 100644 index 000000000..3ef06a5eb --- /dev/null +++ b/examples/tracker-scrape.md @@ -0,0 +1,47 @@ +# Count the number of peers using the `scrape` feature of torrent trackers + +Here's a full example with `browserify`. + +``` +npm install browserify parse-torrent bittorrent-tracker +``` + +`scrape.js`: + +```js +var Tracker = require('bittorrent-tracker') +var magnet = require('magnet-uri') + +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" + +var parsedTorrent = magnet(magnetURI) + +var opts = { + infoHash: parsedTorrent.infoHash, + announce: parsedTorrent.announce, + peerId: new Buffer('01234567890123456789'), // hex string or Buffer + port: 6881 // torrent client port +} + +var client = new Tracker(opts) + +client.scrape() + +client.on('scrape', function (data) { + console.log(data) +}) +``` + +Bundle up `scrape.js` and it's dependencies into a single file called `bundle.js`: + +```bash +browserify scrape.js -o bundle.js +``` + +`index.html`: + +```js + +``` + +Open `index.html` in your browser. diff --git a/img.png b/img/img.png similarity index 100% rename from img.png rename to img/img.png diff --git a/img/trackerStats.png b/img/trackerStats.png new file mode 100644 index 000000000..54ef54bb7 Binary files /dev/null and b/img/trackerStats.png differ diff --git a/index.js b/index.js index 1f79a6e63..e812c41c8 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,6 @@ -var Client = require('./client') -var Server = require('./server') +/*! bittorrent-tracker. MIT License. WebTorrent LLC */ +import Client from './client.js' +import Server from './server.js' -module.exports = Client -module.exports.Client = Client -module.exports.Server = Server +export default Client +export { Client, Server } diff --git a/lib/client/http-tracker.js b/lib/client/http-tracker.js new file mode 100644 index 000000000..43464e8eb --- /dev/null +++ b/lib/client/http-tracker.js @@ -0,0 +1,274 @@ +import arrayRemove from 'unordered-array-remove' +import bencode from 'bencode' +import Debug from 'debug' +import fetch from 'cross-fetch-ponyfill' +import { bin2hex, hex2bin, arr2text, text2arr, arr2hex } from 'uint8-util' + +import common from '../common.js' +import Tracker from './tracker.js' +import compact2string from 'compact2string' + +const debug = Debug('bittorrent-tracker:http-tracker') +const HTTP_SCRAPE_SUPPORT = /\/(announce)[^/]*$/ + +function abortTimeout (ms) { + const controller = new AbortController() + setTimeout(() => { + controller.abort() + }, ms).unref?.() + return controller +} + +/** + * HTTP torrent tracker client (for an individual tracker) + * + * @param {Client} client parent bittorrent tracker client + * @param {string} announceUrl announce url of tracker + * @param {Object} opts options object + */ +class HTTPTracker extends Tracker { + constructor (client, announceUrl) { + super(client, announceUrl) + + debug('new http tracker %s', announceUrl) + + // Determine scrape url (if http tracker supports it) + this.scrapeUrl = null + + const match = this.announceUrl.match(HTTP_SCRAPE_SUPPORT) + if (match) { + const pre = this.announceUrl.slice(0, match.index) + const post = this.announceUrl.slice(match.index + 9) + this.scrapeUrl = `${pre}/scrape${post}` + } + + this.cleanupFns = [] + this.maybeDestroyCleanup = null + } + + announce (opts) { + if (this.destroyed) return + + const params = Object.assign({}, opts, { + compact: (opts.compact == null) ? 1 : opts.compact, + info_hash: this.client._infoHashBinary, + peer_id: this.client._peerIdBinary, + port: this.client._port + }) + + if (params.left !== 0 && !params.left) params.left = 16384 + if (this._trackerId) params.trackerid = this._trackerId + + this._request(this.announceUrl, params, (err, data) => { + if (err) return this.client.emit('warning', err) + this._onAnnounceResponse(data) + }) + } + + scrape (opts) { + if (this.destroyed) return + + if (!this.scrapeUrl) { + this.client.emit('error', new Error(`scrape not supported ${this.announceUrl}`)) + return + } + + const infoHashes = (Array.isArray(opts.infoHash) && opts.infoHash.length > 0) + ? opts.infoHash.map(infoHash => hex2bin(infoHash)) + : (opts.infoHash && hex2bin(opts.infoHash)) || this.client._infoHashBinary + const params = { + info_hash: infoHashes + } + this._request(this.scrapeUrl, params, (err, data) => { + if (err) return this.client.emit('warning', err) + this._onScrapeResponse(data) + }) + } + + destroy (cb) { + const self = this + if (this.destroyed) return cb(null) + this.destroyed = true + clearInterval(this.interval) + + let timeout + + // If there are no pending requests, destroy immediately. + if (this.cleanupFns.length === 0) return destroyCleanup() + + // Otherwise, wait a short time for pending requests to complete, then force + // destroy them. + timeout = setTimeout(destroyCleanup, common.DESTROY_TIMEOUT) + + // But, if all pending requests complete before the timeout fires, do cleanup + // right away. + this.maybeDestroyCleanup = () => { + if (this.cleanupFns.length === 0) destroyCleanup() + } + + function destroyCleanup () { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + self.maybeDestroyCleanup = null + self.cleanupFns.slice(0).forEach(cleanup => { + cleanup() + }) + self.cleanupFns = [] + cb(null) + } + } + + async _request (requestUrl, params, cb) { + const parsedUrl = new URL(requestUrl + (requestUrl.indexOf('?') === -1 ? '?' : '&') + common.querystringStringify(params)) + let agent + if (this.client._proxyOpts) { + agent = parsedUrl.protocol === 'https:' ? this.client._proxyOpts.httpsAgent : this.client._proxyOpts.httpAgent + if (!agent && this.client._proxyOpts.socksProxy) { + agent = this.client._proxyOpts.socksProxy + } + } + + const cleanup = () => { + if (!controller.signal.aborted) { + arrayRemove(this.cleanupFns, this.cleanupFns.indexOf(cleanup)) + controller.abort() + controller = null + } + if (this.maybeDestroyCleanup) this.maybeDestroyCleanup() + } + + this.cleanupFns.push(cleanup) + + let res + let controller = abortTimeout(common.REQUEST_TIMEOUT) + try { + res = await fetch(parsedUrl.toString(), { + agent, + signal: controller.signal, + dispatcher: agent, + headers: { + 'user-agent': this.client._userAgent || '' + } + }) + if (res.body.on) res.body.on('error', cb) + } catch (err) { + if (err) return cb(err) + } + let data = new Uint8Array(await res.arrayBuffer()) + cleanup() + if (this.destroyed) return + + if (res.status !== 200) { + return cb(new Error(`Non-200 response code ${res.status} from ${this.announceUrl}`)) + } + if (!data || data.length === 0) { + return cb(new Error(`Invalid tracker response from${this.announceUrl}`)) + } + + try { + data = bencode.decode(data) + } catch (err) { + return cb(new Error(`Error decoding tracker response: ${err.message}`)) + } + const failure = data['failure reason'] && arr2text(data['failure reason']) + if (failure) { + debug(`failure from ${requestUrl} (${failure})`) + return cb(new Error(failure)) + } + + const warning = data['warning message'] && arr2text(data['warning message']) + if (warning) { + debug(`warning from ${requestUrl} (${warning})`) + this.client.emit('warning', new Error(warning)) + } + + debug(`response from ${requestUrl}`) + + cb(null, data) + } + + _onAnnounceResponse (data) { + const interval = data.interval || data['min interval'] + if (interval) this.setInterval(interval * 1000) + + const trackerId = data['tracker id'] + if (trackerId) { + // If absent, do not discard previous trackerId value + this._trackerId = trackerId + } + + const response = Object.assign({}, data, { + announce: this.announceUrl, + infoHash: bin2hex(data.info_hash || String(data.info_hash)) + }) + this.client.emit('update', response) + + let addrs + if (ArrayBuffer.isView(data.peers)) { + // tracker returned compact response + try { + addrs = compact2string.multi(Buffer.from(data.peers)) + } catch (err) { + return this.client.emit('warning', err) + } + addrs.forEach(addr => { + this.client.emit('peer', addr) + }) + } else if (Array.isArray(data.peers)) { + // tracker returned normal response + data.peers.forEach(peer => { + this.client.emit('peer', `${peer.ip}:${peer.port}`) + }) + } + + if (ArrayBuffer.isView(data.peers6)) { + // tracker returned compact response + try { + addrs = compact2string.multi6(Buffer.from(data.peers6)) + } catch (err) { + return this.client.emit('warning', err) + } + addrs.forEach(addr => { + this.client.emit('peer', addr) + }) + } else if (Array.isArray(data.peers6)) { + // tracker returned normal response + data.peers6.forEach(peer => { + const ip = /^\[/.test(peer.ip) || !/:/.test(peer.ip) + ? peer.ip /* ipv6 w/ brackets or domain name */ + : `[${peer.ip}]` /* ipv6 without brackets */ + this.client.emit('peer', `${ip}:${peer.port}`) + }) + } + } + + _onScrapeResponse (data) { + // NOTE: the unofficial spec says to use the 'files' key, 'host' has been + // seen in practice + data = data.files || data.host || {} + + const keys = Object.keys(data) + if (keys.length === 0) { + this.client.emit('warning', new Error('invalid scrape response')) + return + } + + keys.forEach(_infoHash => { + // TODO: optionally handle data.flags.min_request_interval + // (separate from announce interval) + const infoHash = _infoHash.length !== 20 ? arr2hex(text2arr(_infoHash)) : bin2hex(_infoHash) + + const response = Object.assign(data[_infoHash], { + announce: this.announceUrl, + infoHash + }) + this.client.emit('scrape', response) + }) + } +} + +HTTPTracker.prototype.DEFAULT_ANNOUNCE_INTERVAL = 30 * 60 * 1000 // 30 minutes + +export default HTTPTracker diff --git a/lib/client/tracker.js b/lib/client/tracker.js new file mode 100644 index 000000000..1129ecd84 --- /dev/null +++ b/lib/client/tracker.js @@ -0,0 +1,28 @@ +import EventEmitter from 'events' + +class Tracker extends EventEmitter { + constructor (client, announceUrl) { + super() + + this.client = client + this.announceUrl = announceUrl + + this.interval = null + this.destroyed = false + } + + setInterval (intervalMs) { + if (intervalMs == null) intervalMs = this.DEFAULT_ANNOUNCE_INTERVAL + + clearInterval(this.interval) + + if (intervalMs) { + this.interval = setInterval(() => { + this.announce(this.client._defaultAnnounceOpts()) + }, intervalMs) + if (this.interval.unref) this.interval.unref() + } + } +} + +export default Tracker diff --git a/lib/client/udp-tracker.js b/lib/client/udp-tracker.js new file mode 100644 index 000000000..b4ac4bdba --- /dev/null +++ b/lib/client/udp-tracker.js @@ -0,0 +1,336 @@ +import arrayRemove from 'unordered-array-remove' +import Debug from 'debug' +import dgram from 'dgram' +import Socks from 'socks' +import { concat, hex2arr, randomBytes } from 'uint8-util' + +import common from '../common.js' +import Tracker from './tracker.js' +import compact2string from 'compact2string' + +const debug = Debug('bittorrent-tracker:udp-tracker') + +// this was done some many years ago to fix "prevent Socks instances concurrency", and used some bloated package, no clue if it's needed, but this is simpler, #356 +const clone = obj => JSON.parse(JSON.stringify(obj)) + +/** + * UDP torrent tracker client (for an individual tracker) + * + * @param {Client} client parent bittorrent tracker client + * @param {string} announceUrl announce url of tracker + * @param {Object} opts options object + */ +class UDPTracker extends Tracker { + constructor (client, announceUrl) { + super(client, announceUrl) + debug('new udp tracker %s', announceUrl) + + this.cleanupFns = [] + this.maybeDestroyCleanup = null + } + + announce (opts) { + if (this.destroyed) return + this._request(opts) + } + + scrape (opts) { + if (this.destroyed) return + opts._scrape = true + this._request(opts) // udp scrape uses same announce url + } + + destroy (cb) { + const self = this + if (this.destroyed) return cb(null) + this.destroyed = true + clearInterval(this.interval) + + let timeout + + // If there are no pending requests, destroy immediately. + if (this.cleanupFns.length === 0) return destroyCleanup() + + // Otherwise, wait a short time for pending requests to complete, then force + // destroy them. + timeout = setTimeout(destroyCleanup, common.DESTROY_TIMEOUT) + + // But, if all pending requests complete before the timeout fires, do cleanup + // right away. + this.maybeDestroyCleanup = () => { + if (this.cleanupFns.length === 0) destroyCleanup() + } + + function destroyCleanup () { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + self.maybeDestroyCleanup = null + self.cleanupFns.slice(0).forEach(cleanup => { + cleanup() + }) + self.cleanupFns = [] + cb(null) + } + } + + _request (opts) { + const self = this + if (!opts) opts = {} + + let { hostname, port } = common.parseUrl(this.announceUrl) + if (port === '') port = 80 + + let timeout + // Socket used to connect to the socks server to create a relay, null if socks is disabled + let proxySocket + // Socket used to connect to the tracker or to the socks relay if socks is enabled + let socket + // Contains the host/port of the socks relay + let relay + + let transactionId = genTransactionId() + + const proxyOpts = this.client._proxyOpts && clone(this.client._proxyOpts.socksProxy) + if (proxyOpts) { + if (!proxyOpts.proxy) proxyOpts.proxy = {} + // UDP requests uses the associate command + proxyOpts.proxy.command = 'associate' + if (!proxyOpts.target) { + // This should contain client IP and port but can be set to 0 if we don't have this information + proxyOpts.target = { + host: '0.0.0.0', + port: 0 + } + } + + if (proxyOpts.proxy.type === 5) { + Socks.createConnection(proxyOpts, onGotConnection) + } else { + debug('Ignoring Socks proxy for UDP request because type 5 is required') + onGotConnection(null) + } + } else { + onGotConnection(null) + } + + this.cleanupFns.push(cleanup) + + function onGotConnection (err, s, info) { + if (err) return onError(err) + + proxySocket = s + socket = dgram.createSocket('udp4') + relay = info + + timeout = setTimeout(() => { + // does not matter if `stopped` event arrives, so supress errors + if (opts.event === 'stopped') cleanup() + else onError(new Error(`tracker request timed out (${opts.event})`)) + timeout = null + }, common.REQUEST_TIMEOUT) + if (timeout.unref) timeout.unref() + + send(concat([ + common.CONNECTION_ID, + common.toUInt32(common.ACTIONS.CONNECT), + transactionId + ]), relay) + + socket.once('error', onError) + socket.on('message', onSocketMessage) + } + + function cleanup () { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + if (socket) { + arrayRemove(self.cleanupFns, self.cleanupFns.indexOf(cleanup)) + socket.removeListener('error', onError) + socket.removeListener('message', onSocketMessage) + socket.on('error', noop) // ignore all future errors + try { socket.close() } catch (err) {} + socket = null + if (proxySocket) { + try { proxySocket.close() } catch (err) {} + proxySocket = null + } + } + if (self.maybeDestroyCleanup) self.maybeDestroyCleanup() + } + + function onError (err) { + cleanup() + if (self.destroyed) return + + try { + // Error.message is readonly on some platforms. + if (err.message) err.message += ` (${self.announceUrl})` + } catch (ignoredErr) {} + // errors will often happen if a tracker is offline, so don't treat it as fatal + self.client.emit('warning', err) + } + + function onSocketMessage (msg) { + if (proxySocket) msg = msg.slice(10) + const view = new DataView(transactionId.buffer) + if (msg.length < 8 || msg.readUInt32BE(4) !== view.getUint32(0)) { + return onError(new Error('tracker sent invalid transaction id')) + } + + const action = msg.readUInt32BE(0) + debug('UDP response %s, action %s', self.announceUrl, action) + switch (action) { + case 0: { // handshake + // Note: no check for `self.destroyed` so that pending messages to the + // tracker can still be sent/received even after destroy() is called + + if (msg.length < 16) return onError(new Error('invalid udp handshake')) + + if (opts._scrape) scrape(msg.slice(8, 16)) + else announce(msg.slice(8, 16), opts) + + break + } + case 1: { // announce + cleanup() + if (self.destroyed) return + + if (msg.length < 20) return onError(new Error('invalid announce message')) + + const interval = msg.readUInt32BE(8) + if (interval) self.setInterval(interval * 1000) + + self.client.emit('update', { + announce: self.announceUrl, + complete: msg.readUInt32BE(16), + incomplete: msg.readUInt32BE(12) + }) + + let addrs + try { + addrs = compact2string.multi(msg.slice(20)) + } catch (err) { + return self.client.emit('warning', err) + } + addrs.forEach(addr => { + self.client.emit('peer', addr) + }) + + break + } + case 2: { // scrape + cleanup() + if (self.destroyed) return + + if (msg.length < 20 || (msg.length - 8) % 12 !== 0) { + return onError(new Error('invalid scrape message')) + } + const infoHashes = (Array.isArray(opts.infoHash) && opts.infoHash.length > 0) + ? opts.infoHash.map(infoHash => infoHash.toString('hex')) + : [(opts.infoHash && opts.infoHash.toString('hex')) || self.client.infoHash] + + for (let i = 0, len = (msg.length - 8) / 12; i < len; i += 1) { + self.client.emit('scrape', { + announce: self.announceUrl, + infoHash: infoHashes[i], + complete: msg.readUInt32BE(8 + (i * 12)), + downloaded: msg.readUInt32BE(12 + (i * 12)), + incomplete: msg.readUInt32BE(16 + (i * 12)) + }) + } + + break + } + case 3: { // error + cleanup() + if (self.destroyed) return + + if (msg.length < 8) return onError(new Error('invalid error message')) + self.client.emit('warning', new Error(msg.slice(8).toString())) + + break + } + default: + onError(new Error('tracker sent invalid action')) + break + } + } + + function send (message, proxyInfo) { + if (proxyInfo) { + const pack = Socks.createUDPFrame({ host: hostname, port }, message) + socket.send(pack, 0, pack.length, proxyInfo.port, proxyInfo.host) + } else { + socket.send(message, 0, message.length, port, hostname) + } + } + + function announce (connectionId, opts) { + transactionId = genTransactionId() + + send(concat([ + connectionId, + common.toUInt32(common.ACTIONS.ANNOUNCE), + transactionId, + self.client._infoHashBuffer, + self.client._peerIdBuffer, + toUInt64(opts.downloaded), + opts.left != null ? toUInt64(opts.left) : hex2arr('ffffffffffffffff'), + toUInt64(opts.uploaded), + common.toUInt32(common.EVENTS[opts.event] || 0), + common.toUInt32(0), // ip address (optional) + common.toUInt32(0), // key (optional) + common.toUInt32(opts.numwant), + toUInt16(self.client._port) + ]), relay) + } + + function scrape (connectionId) { + transactionId = genTransactionId() + + const infoHash = (Array.isArray(opts.infoHash) && opts.infoHash.length > 0) + ? concat(opts.infoHash) + : (opts.infoHash || self.client._infoHashBuffer) + + send(concat([ + connectionId, + common.toUInt32(common.ACTIONS.SCRAPE), + transactionId, + infoHash + ]), relay) + } + } +} + +UDPTracker.prototype.DEFAULT_ANNOUNCE_INTERVAL = 30 * 60 * 1000 // 30 minutes + +function genTransactionId () { + return randomBytes(4) +} + +function toUInt16 (n) { + const buf = new Uint8Array(2) + const view = new DataView(buf.buffer) + view.setUint16(0, n) + return buf +} + +const MAX_UINT = 4294967295 + +function toUInt64 (n) { + if (n > MAX_UINT || typeof n === 'string') { + const buf = new Uint8Array(8) + const view = new DataView(buf.buffer) + view.setBigUint64(0, BigInt(n)) + return buf + } + return concat([new Uint8Array(4), common.toUInt32(n)]) +} + +function noop () {} + +export default UDPTracker diff --git a/lib/client/websocket-tracker.js b/lib/client/websocket-tracker.js new file mode 100644 index 000000000..c4e3e23d7 --- /dev/null +++ b/lib/client/websocket-tracker.js @@ -0,0 +1,442 @@ +import Debug from 'debug' +import Peer from '@thaunknown/simple-peer/lite.js' +import Socket from '@thaunknown/simple-websocket' +import { arr2text, arr2hex, hex2bin, bin2hex, randomBytes } from 'uint8-util' + +import common from '../common.js' +import Tracker from './tracker.js' + +const debug = Debug('bittorrent-tracker:websocket-tracker') + +// Use a socket pool, so tracker clients share WebSocket objects for the same server. +// In practice, WebSockets are pretty slow to establish, so this gives a nice performance +// boost, and saves browser resources. +const socketPool = {} + +const RECONNECT_MINIMUM = 10 * 1000 +const RECONNECT_MAXIMUM = 60 * 60 * 1000 +const RECONNECT_VARIANCE = 5 * 60 * 1000 +const OFFER_TIMEOUT = 50 * 1000 + +class WebSocketTracker extends Tracker { + constructor (client, announceUrl) { + super(client, announceUrl) + debug('new websocket tracker %s', announceUrl) + + this.peers = {} // peers (offer id -> peer) + this.socket = null + + this.reconnecting = false + this.retries = 0 + this.reconnectTimer = null + + // Simple boolean flag to track whether the socket has received data from + // the websocket server since the last time socket.send() was called. + this.expectingResponse = false + + this._openSocket() + } + + announce (opts) { + if (this.destroyed || this.reconnecting) return + if (!this.socket.connected) { + this.socket.once('connect', () => { + this.announce(opts) + }) + return + } + + const params = Object.assign({}, opts, { + action: 'announce', + info_hash: this.client._infoHashBinary, + peer_id: this.client._peerIdBinary + }) + if (this._trackerId) params.trackerid = this._trackerId + + if (opts.event === 'stopped' || opts.event === 'completed') { + // Don't include offers with 'stopped' or 'completed' event + this._send(params) + } else { + // Limit the number of offers that are generated, since it can be slow + const numwant = Math.min(opts.numwant, 5) + + this._generateOffers(numwant, offers => { + params.numwant = numwant + params.offers = offers + this._send(params) + }) + } + } + + scrape (opts) { + if (this.destroyed || this.reconnecting) return + if (!this.socket.connected) { + this.socket.once('connect', () => { + this.scrape(opts) + }) + return + } + + const infoHashes = (Array.isArray(opts.infoHash) && opts.infoHash.length > 0) + ? opts.infoHash.map(infoHash => hex2bin(infoHash)) + : (opts.infoHash && hex2bin(opts.infoHash)) || this.client._infoHashBinary + const params = { + action: 'scrape', + info_hash: infoHashes + } + + this._send(params) + } + + destroy (cb = noop) { + if (this.destroyed) return cb(null) + + this.destroyed = true + + clearInterval(this.interval) + clearTimeout(this.reconnectTimer) + + // Destroy peers + for (const peerId in this.peers) { + const peer = this.peers[peerId] + clearTimeout(peer.trackerTimeout) + peer.destroy() + } + this.peers = null + + if (this.socket) { + this.socket.removeListener('connect', this._onSocketConnectBound) + this.socket.removeListener('data', this._onSocketDataBound) + this.socket.removeListener('close', this._onSocketCloseBound) + this.socket.removeListener('error', this._onSocketErrorBound) + this.socket = null + } + + this._onSocketConnectBound = null + this._onSocketErrorBound = null + this._onSocketDataBound = null + this._onSocketCloseBound = null + + if (socketPool[this.announceUrl]) { + socketPool[this.announceUrl].consumers -= 1 + } + + // Other instances are using the socket, so there's nothing left to do here + if (socketPool[this.announceUrl].consumers > 0) return cb() + + let socket = socketPool[this.announceUrl] + delete socketPool[this.announceUrl] + socket.on('error', noop) // ignore all future errors + socket.once('close', cb) + + let timeout + + // If there is no data response expected, destroy immediately. + if (!this.expectingResponse) return destroyCleanup() + + // Otherwise, wait a short time for potential responses to come in from the + // server, then force close the socket. + timeout = setTimeout(destroyCleanup, common.DESTROY_TIMEOUT) + + // But, if a response comes from the server before the timeout fires, do cleanup + // right away. + socket.once('data', destroyCleanup) + + function destroyCleanup () { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + socket.removeListener('data', destroyCleanup) + socket.destroy() + socket = null + } + } + + _openSocket () { + this.destroyed = false + + if (!this.peers) this.peers = {} + + this._onSocketConnectBound = () => { + this._onSocketConnect() + } + this._onSocketErrorBound = err => { + this._onSocketError(err) + } + this._onSocketDataBound = data => { + this._onSocketData(data) + } + this._onSocketCloseBound = () => { + this._onSocketClose() + } + + this.socket = socketPool[this.announceUrl] + if (this.socket) { + socketPool[this.announceUrl].consumers += 1 + if (this.socket.connected) { + this._onSocketConnectBound() + } + } else { + const parsedUrl = new URL(this.announceUrl) + let agent + if (this.client._proxyOpts) { + agent = parsedUrl.protocol === 'wss:' ? this.client._proxyOpts.httpsAgent : this.client._proxyOpts.httpAgent + if (!agent && this.client._proxyOpts.socksProxy) { + agent = this.client._proxyOpts.socksProxy + } + } + this.socket = socketPool[this.announceUrl] = new Socket({ url: this.announceUrl, agent }) + this.socket.consumers = 1 + this.socket.once('connect', this._onSocketConnectBound) + } + + this.socket.on('data', this._onSocketDataBound) + this.socket.once('close', this._onSocketCloseBound) + this.socket.once('error', this._onSocketErrorBound) + } + + _onSocketConnect () { + if (this.destroyed) return + + if (this.reconnecting) { + this.reconnecting = false + this.retries = 0 + this.announce(this.client._defaultAnnounceOpts()) + } + } + + _onSocketData (data) { + if (this.destroyed) return + + this.expectingResponse = false + + try { + data = JSON.parse(arr2text(data)) + } catch (err) { + this.client.emit('warning', new Error('Invalid tracker response')) + return + } + + if (data.action === 'announce') { + this._onAnnounceResponse(data) + } else if (data.action === 'scrape') { + this._onScrapeResponse(data) + } else { + this._onSocketError(new Error(`invalid action in WS response: ${data.action}`)) + } + } + + _onAnnounceResponse (data) { + if (data.info_hash !== this.client._infoHashBinary) { + debug( + 'ignoring websocket data from %s for %s (looking for %s: reused socket)', + this.announceUrl, bin2hex(data.info_hash), this.client.infoHash + ) + return + } + + if (data.peer_id && data.peer_id === this.client._peerIdBinary) { + // ignore offers/answers from this client + return + } + + debug( + 'received %s from %s for %s', + JSON.stringify(data), this.announceUrl, this.client.infoHash + ) + + const failure = data['failure reason'] + if (failure) return this.client.emit('warning', new Error(failure)) + + const warning = data['warning message'] + if (warning) this.client.emit('warning', new Error(warning)) + + const interval = data.interval || data['min interval'] + if (interval) this.setInterval(interval * 1000) + + const trackerId = data['tracker id'] + if (trackerId) { + // If absent, do not discard previous trackerId value + this._trackerId = trackerId + } + + if (data.complete != null) { + const response = Object.assign({}, data, { + announce: this.announceUrl, + infoHash: bin2hex(data.info_hash) + }) + this.client.emit('update', response) + } + + let peer + if (data.offer && data.peer_id) { + debug('creating peer (from remote offer)') + peer = this._createPeer() + peer.id = bin2hex(data.peer_id) + peer.once('signal', answer => { + const params = { + action: 'announce', + info_hash: this.client._infoHashBinary, + peer_id: this.client._peerIdBinary, + to_peer_id: data.peer_id, + answer, + offer_id: data.offer_id + } + if (this._trackerId) params.trackerid = this._trackerId + this._send(params) + }) + this.client.emit('peer', peer) + peer.signal(data.offer) + } + + if (data.answer && data.peer_id) { + const offerId = bin2hex(data.offer_id) + peer = this.peers[offerId] + if (peer) { + peer.id = bin2hex(data.peer_id) + this.client.emit('peer', peer) + peer.signal(data.answer) + + clearTimeout(peer.trackerTimeout) + peer.trackerTimeout = null + delete this.peers[offerId] + } else { + debug(`got unexpected answer: ${JSON.stringify(data.answer)}`) + } + } + } + + _onScrapeResponse (data) { + data = data.files || {} + + const keys = Object.keys(data) + if (keys.length === 0) { + this.client.emit('warning', new Error('invalid scrape response')) + return + } + + keys.forEach(infoHash => { + // TODO: optionally handle data.flags.min_request_interval + // (separate from announce interval) + const response = Object.assign(data[infoHash], { + announce: this.announceUrl, + infoHash: bin2hex(infoHash) + }) + this.client.emit('scrape', response) + }) + } + + _onSocketClose () { + if (this.destroyed) return + this.destroy() + this._startReconnectTimer() + } + + _onSocketError (err) { + if (this.destroyed) return + this.destroy() + // errors will often happen if a tracker is offline, so don't treat it as fatal + this.client.emit('warning', err) + this._startReconnectTimer() + } + + _startReconnectTimer () { + const ms = Math.floor(Math.random() * RECONNECT_VARIANCE) + Math.min(Math.pow(2, this.retries) * RECONNECT_MINIMUM, RECONNECT_MAXIMUM) + + this.reconnecting = true + clearTimeout(this.reconnectTimer) + this.reconnectTimer = setTimeout(() => { + this.retries++ + this._openSocket() + }, ms) + if (this.reconnectTimer.unref) this.reconnectTimer.unref() + + debug('reconnecting socket in %s ms', ms) + } + + _send (params) { + if (this.destroyed) return + this.expectingResponse = true + const message = JSON.stringify(params) + debug('send %s', message) + this.socket.send(message) + } + + _generateOffers (numwant, cb) { + const self = this + const offers = [] + debug('generating %s offers', numwant) + + for (let i = 0; i < numwant; ++i) { + generateOffer() + } + checkDone() + + function generateOffer () { + const offerId = arr2hex(randomBytes(20)) + debug('creating peer (from _generateOffers)') + const peer = self.peers[offerId] = self._createPeer({ initiator: true }) + peer.once('signal', offer => { + offers.push({ + offer, + offer_id: hex2bin(offerId) + }) + checkDone() + }) + peer.trackerTimeout = setTimeout(() => { + debug('tracker timeout: destroying peer') + peer.trackerTimeout = null + delete self.peers[offerId] + peer.destroy() + }, OFFER_TIMEOUT) + if (peer.trackerTimeout.unref) peer.trackerTimeout.unref() + } + + function checkDone () { + if (offers.length === numwant) { + debug('generated %s offers', numwant) + cb(offers) + } + } + } + + _createPeer (opts) { + const self = this + + opts = Object.assign({ + trickle: false, + config: self.client._rtcConfig, + wrtc: self.client._wrtc + }, opts) + + const peer = new Peer(opts) + + peer.once('error', onError) + peer.once('connect', onConnect) + + return peer + + // Handle peer 'error' events that are fired *before* the peer is emitted in + // a 'peer' event. + function onError (err) { + self.client.emit('warning', new Error(`Connection error: ${err.message}`)) + peer.destroy() + } + + // Once the peer is emitted in a 'peer' event, then it's the consumer's + // responsibility to listen for errors, so the listeners are removed here. + function onConnect () { + peer.removeListener('error', onError) + peer.removeListener('connect', onConnect) + } + } +} + +WebSocketTracker.prototype.DEFAULT_ANNOUNCE_INTERVAL = 30 * 1000 // 30 seconds +// Normally this shouldn't be accessed but is occasionally useful +WebSocketTracker._socketPool = socketPool + +function noop () {} + +export default WebSocketTracker diff --git a/lib/common-node.js b/lib/common-node.js index 12c45b216..9fefa5c4d 100644 --- a/lib/common-node.js +++ b/lib/common-node.js @@ -3,33 +3,49 @@ * These are separate from common.js so they can be skipped when bundling for the browser. */ -var querystring = require('querystring') +import querystring from 'querystring' +import { concat } from 'uint8-util' -exports.IPV4_RE = /^[\d\.]+$/ -exports.IPV6_RE = /^[\da-fA-F:]+$/ +export const IPV4_RE = /^[\d.]+$/ +export const IPV6_RE = /^[\da-fA-F:]+$/ +export const REMOVE_IPV4_MAPPED_IPV6_RE = /^::ffff:/ -exports.CONNECTION_ID = Buffer.concat([ toUInt32(0x417), toUInt32(0x27101980) ]) -exports.ACTIONS = { CONNECT: 0, ANNOUNCE: 1, SCRAPE: 2, ERROR: 3 } -exports.EVENTS = { update: 0, completed: 1, started: 2, stopped: 3 } -exports.EVENT_IDS = { +export const CONNECTION_ID = concat([toUInt32(0x417), toUInt32(0x27101980)]) +export const ACTIONS = { CONNECT: 0, ANNOUNCE: 1, SCRAPE: 2, ERROR: 3 } +export const EVENTS = { update: 0, completed: 1, started: 2, stopped: 3, paused: 4 } +export const EVENT_IDS = { 0: 'update', 1: 'completed', 2: 'started', - 3: 'stopped' + 3: 'stopped', + 4: 'paused' } -exports.EVENT_NAMES = { +export const EVENT_NAMES = { update: 'update', completed: 'complete', started: 'start', - stopped: 'stop' + stopped: 'stop', + paused: 'pause' } -function toUInt32 (n) { - var buf = new Buffer(4) - buf.writeUInt32BE(n, 0) +/** + * Client request timeout. How long to wait before considering a request to a + * tracker server to have timed out. + */ +export const REQUEST_TIMEOUT = 15000 + +/** + * Client destroy timeout. How long to wait before forcibly cleaning up all + * pending requests, open sockets, etc. + */ +export const DESTROY_TIMEOUT = 1000 + +export function toUInt32 (n) { + const buf = new Uint8Array(4) + const view = new DataView(buf.buffer) + view.setUint32(0, n) return buf } -exports.toUInt32 = toUInt32 /** * `querystring.parse` using `unescape` instead of decodeURIComponent, since bittorrent @@ -37,13 +53,7 @@ exports.toUInt32 = toUInt32 * @param {string} q * @return {Object} */ -exports.querystringParse = function (q) { - var saved = querystring.unescape - querystring.unescape = unescape // global - var ret = querystring.parse(q) - querystring.unescape = saved - return ret -} +export const querystringParse = q => querystring.parse(q, null, null, { decodeURIComponent: unescape }) /** * `querystring.stringify` using `escape` instead of encodeURIComponent, since bittorrent @@ -51,14 +61,9 @@ exports.querystringParse = function (q) { * @param {Object} obj * @return {string} */ -exports.querystringStringify = function (obj) { - var saved = querystring.escape - querystring.escape = escape // global - var ret = querystring.stringify(obj) - ret = ret.replace(/[\@\*\/\+]/g, function (char) { - // `escape` doesn't encode the characters @*/+ so we do it manually - return '%' + char.charCodeAt(0).toString(16).toUpperCase() - }) - querystring.escape = saved +export const querystringStringify = obj => { + let ret = querystring.stringify(obj, null, null, { encodeURIComponent: escape }) + ret = ret.replace(/[@*/+]/g, char => // `escape` doesn't encode the characters @*/+ so we do it manually + `%${char.charCodeAt(0).toString(16).toUpperCase()}`) return ret } diff --git a/lib/common.js b/lib/common.js index a6f191a19..ef888579c 100644 --- a/lib/common.js +++ b/lib/common.js @@ -1,21 +1,41 @@ /** * Functions/constants needed by both the client and server. */ +import * as common from './common-node.js' +export * from './common-node.js' -var extend = require('xtend/mutable') +export const DEFAULT_ANNOUNCE_PEERS = 50 +export const MAX_ANNOUNCE_PEERS = 82 -exports.DEFAULT_ANNOUNCE_INTERVAL = 30 * 60 * 1000 // 30 minutes +// HACK: Fix for WHATWG URL object not parsing non-standard URL schemes like +// 'udp:'. Just replace it with 'http:' since we only need a few properties. +// +// Note: Only affects Chrome and Firefox. Works fine in Node.js, Safari, and +// Edge. +// +// Note: UDP trackers aren't used in the normal browser build, but they are +// used in a Chrome App build (i.e. by Brave Browser). +// +// Bug reports: +// - Chrome: https://bugs.chromium.org/p/chromium/issues/detail?id=734880 +// - Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=1374505 +export const parseUrl = str => { + const url = new URL(str.replace(/^udp:/, 'http:')) -exports.DEFAULT_ANNOUNCE_PEERS = 50 -exports.MAX_ANNOUNCE_PEERS = 82 + if (str.match(/^udp:/)) { + Object.defineProperties(url, { + href: { value: url.href.replace(/^http/, 'udp') }, + protocol: { value: url.protocol.replace(/^http/, 'udp') }, + origin: { value: url.origin.replace(/^http/, 'udp') } + }) + } -exports.binaryToHex = function (str) { - return new Buffer(str, 'binary').toString('hex') + return url } -exports.hexToBinary = function (str) { - return new Buffer(str, 'hex').toString('binary') +export default { + DEFAULT_ANNOUNCE_PEERS, + MAX_ANNOUNCE_PEERS, + parseUrl, + ...common } - -var config = require('./common-node') -extend(exports, config) diff --git a/lib/http-tracker.js b/lib/http-tracker.js deleted file mode 100644 index 48d033e20..000000000 --- a/lib/http-tracker.js +++ /dev/null @@ -1,224 +0,0 @@ -module.exports = HTTPTracker - -var bencode = require('bencode') -var compact2string = require('compact2string') -var debug = require('debug')('bittorrent-tracker:http-tracker') -var EventEmitter = require('events').EventEmitter -var get = require('simple-get') -var inherits = require('inherits') - -var common = require('./common') - -var HTTP_SCRAPE_SUPPORT = /\/(announce)[^\/]*$/ - -inherits(HTTPTracker, EventEmitter) - -/** - * HTTP torrent tracker client (for an individual tracker) - * - * @param {Client} client parent bittorrent tracker client - * @param {string} announceUrl announce url of tracker - * @param {Object} opts options object - */ -function HTTPTracker (client, announceUrl, opts) { - var self = this - EventEmitter.call(self) - debug('new http tracker %s', announceUrl) - - self.client = client - self.destroyed = false - - self._opts = opts - self._announceUrl = announceUrl - self._intervalMs = self.client._intervalMs // use client interval initially - self._interval = null - - // Determine scrape url (if http tracker supports it) - self._scrapeUrl = null - var m - if ((m = self._announceUrl.match(HTTP_SCRAPE_SUPPORT))) { - self._scrapeUrl = self._announceUrl.slice(0, m.index) + '/scrape' + - self._announceUrl.slice(m.index + 9) - } -} - -HTTPTracker.prototype.announce = function (opts) { - var self = this - if (self.destroyed) return - if (self._trackerId) opts.trackerid = self._trackerId - - if (opts.compact == null) opts.compact = 1 - if (opts.numwant == null) opts.numwant = self.client._numWant // spec says 'numwant' - - opts.info_hash = self.client._infoHashBinary - opts.peer_id = self.client._peerIdBinary - opts.port = self.client._port - - self._request(self._announceUrl, opts, self._onAnnounceResponse.bind(self)) -} - -HTTPTracker.prototype.scrape = function (opts) { - var self = this - if (self.destroyed) return - - if (!self._scrapeUrl) { - self.client.emit('error', new Error('scrape not supported ' + self._announceUrl)) - return - } - - opts.info_hash = (Array.isArray(opts.infoHash) && opts.infoHash.length > 0) - ? opts.infoHash.map(function (infoHash) { return infoHash.toString('binary') }) - : (opts.infoHash || self.client._infoHash).toString('binary') - - if (opts.infoHash) delete opts.infoHash - - self._request(self._scrapeUrl, opts, self._onScrapeResponse.bind(self)) -} - -// TODO: Improve this interface -HTTPTracker.prototype.setInterval = function (intervalMs) { - var self = this - clearInterval(self._interval) - - self._intervalMs = intervalMs - if (intervalMs) { - // HACK - var update = self.announce.bind(self, self.client._defaultAnnounceOpts()) - self._interval = setInterval(update, self._intervalMs) - } -} - -HTTPTracker.prototype.destroy = function (cb) { - var self = this - if (self.destroyed) return - self.destroyed = true - cb(null) -} - -HTTPTracker.prototype._request = function (requestUrl, opts, cb) { - var self = this - - var u = requestUrl + (requestUrl.indexOf('?') === -1 ? '?' : '&') + - common.querystringStringify(opts) - - get.concat(u, function (err, data, res) { - if (self.destroyed) return - if (err) return self.client.emit('warning', err) - if (res.statusCode !== 200) { - return self.client.emit('warning', new Error('Non-200 response code ' + - res.statusCode + ' from ' + self._announceUrl)) - } - if (!data || data.length === 0) { - return self.client.emit('warning', new Error('Invalid tracker response from' + - self._announceUrl)) - } - - try { - data = bencode.decode(data) - } catch (err) { - return self.client.emit('warning', new Error('Error decoding tracker response: ' + err.message)) - } - var failure = data['failure reason'] - if (failure) { - debug('failure from ' + requestUrl + ' (' + failure + ')') - return self.client.emit('warning', new Error(failure)) - } - - var warning = data['warning message'] - if (warning) { - debug('warning from ' + requestUrl + ' (' + warning + ')') - self.client.emit('warning', new Error(warning)) - } - - debug('response from ' + requestUrl) - - cb(data) - }) -} - -HTTPTracker.prototype._onAnnounceResponse = function (data) { - var self = this - - var interval = data.interval || data['min interval'] - if (interval && !self._opts.interval && self._intervalMs !== 0) { - // use the interval the tracker recommends, UNLESS the user manually specifies an - // interval they want to use - self.setInterval(interval * 1000) - } - - var trackerId = data['tracker id'] - if (trackerId) { - // If absent, do not discard previous trackerId value - self._trackerId = trackerId - } - - self.client.emit('update', { - announce: self._announceUrl, - complete: data.complete, - incomplete: data.incomplete - }) - - var addrs - if (Buffer.isBuffer(data.peers)) { - // tracker returned compact response - try { - addrs = compact2string.multi(data.peers) - } catch (err) { - return self.client.emit('warning', err) - } - addrs.forEach(function (addr) { - self.client.emit('peer', addr) - }) - } else if (Array.isArray(data.peers)) { - // tracker returned normal response - data.peers.forEach(function (peer) { - self.client.emit('peer', peer.ip + ':' + peer.port) - }) - } - - if (Buffer.isBuffer(data.peers6)) { - // tracker returned compact response - try { - addrs = compact2string.multi6(data.peers6) - } catch (err) { - return self.client.emit('warning', err) - } - addrs.forEach(function (addr) { - self.client.emit('peer', addr) - }) - } else if (Array.isArray(data.peers6)) { - // tracker returned normal response - data.peers6.forEach(function (peer) { - var ip = /^\[/.test(peer.ip) || !/:/.test(peer.ip) - ? peer.ip /* ipv6 w/ brackets or domain name */ - : '[' + peer.ip + ']' /* ipv6 without brackets */ - self.client.emit('peer', ip + ':' + peer.port) - }) - } -} - -HTTPTracker.prototype._onScrapeResponse = function (data) { - var self = this - // NOTE: the unofficial spec says to use the 'files' key, 'host' has been - // seen in practice - data = data.files || data.host || {} - - var keys = Object.keys(data) - if (keys.length === 0) { - self.client.emit('warning', new Error('invalid scrape response')) - return - } - - keys.forEach(function (infoHash) { - var response = data[infoHash] - // TODO: optionally handle data.flags.min_request_interval - // (separate from announce interval) - self.client.emit('scrape', { - announce: self._announceUrl, - infoHash: common.binaryToHex(infoHash), - complete: response.complete, - incomplete: response.incomplete, - downloaded: response.downloaded - }) - }) -} diff --git a/lib/parse_http.js b/lib/parse_http.js deleted file mode 100644 index fa044b4fa..000000000 --- a/lib/parse_http.js +++ /dev/null @@ -1,56 +0,0 @@ -module.exports = parseHttpRequest - -var common = require('./common') - -var REMOVE_IPV4_MAPPED_IPV6_RE = /^::ffff:/ - -function parseHttpRequest (req, opts) { - if (!opts) opts = {} - var s = req.url.split('?') - var params = common.querystringParse(s[1]) - - if (opts.action === 'announce' || s[0] === '/announce') { - params.action = common.ACTIONS.ANNOUNCE - - if (typeof params.info_hash !== 'string' || params.info_hash.length !== 20) { - throw new Error('invalid info_hash') - } - params.info_hash = common.binaryToHex(params.info_hash) - - if (typeof params.peer_id !== 'string' || params.peer_id.length !== 20) { - throw new Error('invalid peer_id') - } - params.peer_id = common.binaryToHex(params.peer_id) - - params.port = Number(params.port) - if (!params.port) throw new Error('invalid port') - - params.left = Number(params.left) || Infinity - params.compact = Number(params.compact) || 0 - params.numwant = Math.min( - Number(params.numwant) || common.DEFAULT_ANNOUNCE_PEERS, - common.MAX_ANNOUNCE_PEERS - ) - - params.ip = opts.trustProxy - ? req.headers['x-forwarded-for'] || req.connection.remoteAddress - : req.connection.remoteAddress.replace(REMOVE_IPV4_MAPPED_IPV6_RE, '') // force ipv4 - params.addr = (common.IPV6_RE.test(params.ip) ? '[' + params.ip + ']' : params.ip) + ':' + params.port - } else if (opts.action === 'scrape' || s[0] === '/scrape') { - params.action = common.ACTIONS.SCRAPE - - if (typeof params.info_hash === 'string') params.info_hash = [ params.info_hash ] - if (Array.isArray(params.info_hash)) { - params.info_hash = params.info_hash.map(function (binaryInfoHash) { - if (typeof binaryInfoHash !== 'string' || binaryInfoHash.length !== 20) { - throw new Error('invalid info_hash') - } - return common.binaryToHex(binaryInfoHash) - }) - } - } else { - throw new Error('invalid action in HTTP request: ' + params.action) - } - - return params -} diff --git a/lib/parse_websocket.js b/lib/parse_websocket.js deleted file mode 100644 index 88de03252..000000000 --- a/lib/parse_websocket.js +++ /dev/null @@ -1,35 +0,0 @@ -module.exports = parseWebSocketRequest - -var common = require('./common') - -function parseWebSocketRequest (socket, params) { - params = JSON.parse(params) // may throw - - params.action = common.ACTIONS.ANNOUNCE - params.socket = socket - - if (typeof params.info_hash !== 'string' || params.info_hash.length !== 20) { - throw new Error('invalid info_hash') - } - params.info_hash = common.binaryToHex(params.info_hash) - - if (typeof params.peer_id !== 'string' || params.peer_id.length !== 20) { - throw new Error('invalid peer_id') - } - params.peer_id = common.binaryToHex(params.peer_id) - - if (params.answer) { - if (typeof params.to_peer_id !== 'string' || params.to_peer_id.length !== 20) { - throw new Error('invalid `to_peer_id` (required with `answer`)') - } - params.to_peer_id = common.binaryToHex(params.to_peer_id) - } - - params.left = Number(params.left) || Infinity - params.numwant = Math.min( - Number(params.offers && params.offers.length) || 0, // no default - explicit only - common.MAX_ANNOUNCE_PEERS - ) - - return params -} diff --git a/lib/server/parse-http.js b/lib/server/parse-http.js new file mode 100644 index 000000000..97b46e307 --- /dev/null +++ b/lib/server/parse-http.js @@ -0,0 +1,67 @@ +import { bin2hex } from 'uint8-util' + +import common from '../common.js' + +export default function (req, opts) { + if (!opts) opts = {} + const s = req.url.split('?') + const params = common.querystringParse(s[1]) + params.type = 'http' + + if (opts.action === 'announce' || s[0] === '/announce') { + params.action = common.ACTIONS.ANNOUNCE + + if (typeof params.info_hash !== 'string' || params.info_hash.length !== 20) { + throw new Error('invalid info_hash') + } + params.info_hash = bin2hex(params.info_hash) + + if (typeof params.peer_id !== 'string' || params.peer_id.length !== 20) { + throw new Error('invalid peer_id') + } + params.peer_id = bin2hex(params.peer_id) + + params.port = Number(params.port) + if (!params.port || params.port <= 0 || params.port > 65535) throw new Error('invalid port') + + params.left = Number(params.left) + if (Number.isNaN(params.left)) params.left = Infinity + + params.compact = Number(params.compact) || 0 + params.numwant = Math.min( + Number(params.numwant) || common.DEFAULT_ANNOUNCE_PEERS, + common.MAX_ANNOUNCE_PEERS + ) + + if (opts.trustProxy) { + if (req.headers['x-forwarded-for']) { + const [realIp] = req.headers['x-forwarded-for'].split(',') + params.ip = realIp.trim() + } else { + params.ip = req.connection.remoteAddress + } + } else { + params.ip = req.connection.remoteAddress.replace(common.REMOVE_IPV4_MAPPED_IPV6_RE, '') // force ipv4 + } + + params.addr = `${common.IPV6_RE.test(params.ip) ? `[${params.ip}]` : params.ip}:${params.port}` + + params.headers = req.headers + } else if (opts.action === 'scrape' || s[0] === '/scrape') { + params.action = common.ACTIONS.SCRAPE + + if (typeof params.info_hash === 'string') params.info_hash = [params.info_hash] + if (Array.isArray(params.info_hash)) { + params.info_hash = params.info_hash.map(binaryInfoHash => { + if (typeof binaryInfoHash !== 'string' || binaryInfoHash.length !== 20) { + throw new Error('invalid info_hash') + } + return bin2hex(binaryInfoHash) + }) + } + } else { + throw new Error(`invalid action in HTTP request: ${req.url}`) + } + + return params +} diff --git a/lib/parse_udp.js b/lib/server/parse-udp.js similarity index 67% rename from lib/parse_udp.js rename to lib/server/parse-udp.js index d46ca4e1c..f73b64917 100644 --- a/lib/parse_udp.js +++ b/lib/server/parse-udp.js @@ -1,20 +1,18 @@ -module.exports = parseUdpRequest +import ipLib from 'ip' +import common from '../common.js' +import { equal } from 'uint8-util' -var bufferEqual = require('buffer-equal') -var ipLib = require('ip') -var common = require('./common') - -function parseUdpRequest (msg, rinfo) { +export default function (msg, rinfo) { if (msg.length < 16) throw new Error('received packet is too short') - var params = { + const params = { connectionId: msg.slice(0, 8), // 64-bit action: msg.readUInt32BE(8), - transactionId: msg.readUInt32BE(12) + transactionId: msg.readUInt32BE(12), + type: 'udp' } - // TODO: randomize - if (!bufferEqual(params.connectionId, common.CONNECTION_ID)) { + if (!equal(common.CONNECTION_ID, params.connectionId)) { throw new Error('received packet with invalid connection id') } @@ -30,7 +28,7 @@ function parseUdpRequest (msg, rinfo) { params.event = common.EVENT_IDS[msg.readUInt32BE(80)] if (!params.event) throw new Error('invalid event') // early return - var ip = msg.readUInt32BE(84) // optional + const ip = msg.readUInt32BE(84) // optional params.ip = ip ? ipLib.toString(ip) : rinfo.address @@ -45,32 +43,32 @@ function parseUdpRequest (msg, rinfo) { ) params.port = msg.readUInt16BE(96) || rinfo.port // optional - params.addr = params.ip + ':' + params.port // TODO: ipv6 brackets + params.addr = `${params.ip}:${params.port}` // TODO: ipv6 brackets params.compact = 1 // udp is always compact } else if (params.action === common.ACTIONS.SCRAPE) { // scrape message if ((msg.length - 16) % 20 !== 0) throw new Error('invalid scrape message') params.info_hash = [] - for (var i = 0, len = (msg.length - 16) / 20; i < len; i += 1) { - var infoHash = msg.slice(16 + (i * 20), 36 + (i * 20)).toString('hex') // 20 bytes + for (let i = 0, len = (msg.length - 16) / 20; i < len; i += 1) { + const infoHash = msg.slice(16 + (i * 20), 36 + (i * 20)).toString('hex') // 20 bytes params.info_hash.push(infoHash) } } else { - throw new Error('Invalid action in UDP packet: ' + params.action) + throw new Error(`Invalid action in UDP packet: ${params.action}`) } return params } -var TWO_PWR_32 = (1 << 16) * 2 +const TWO_PWR_32 = (1 << 16) * 2 /** * Return the closest floating-point representation to the buffer value. Precision will be * lost for big numbers. */ function fromUInt64 (buf) { - var high = buf.readUInt32BE(0) | 0 // force - var low = buf.readUInt32BE(4) | 0 - var lowUnsigned = (low >= 0) ? low : TWO_PWR_32 + low + const high = buf.readUInt32BE(0) | 0 // force + const low = buf.readUInt32BE(4) | 0 + const lowUnsigned = (low >= 0) ? low : TWO_PWR_32 + low - return high * TWO_PWR_32 + lowUnsigned + return (high * TWO_PWR_32) + lowUnsigned } diff --git a/lib/server/parse-websocket.js b/lib/server/parse-websocket.js new file mode 100644 index 000000000..744a3c30b --- /dev/null +++ b/lib/server/parse-websocket.js @@ -0,0 +1,86 @@ +import { bin2hex } from 'uint8-util' + +import common from '../common.js' + +export default function (socket, opts, params) { + if (!opts) opts = {} + params = JSON.parse(params) // may throw + + params.type = 'ws' + params.socket = socket + if (params.action === 'announce') { + params.action = common.ACTIONS.ANNOUNCE + + if (typeof params.info_hash !== 'string' || params.info_hash.length !== 20) { + throw new Error('invalid info_hash') + } + params.info_hash = bin2hex(params.info_hash) + + if (typeof params.peer_id !== 'string' || params.peer_id.length !== 20) { + throw new Error('invalid peer_id') + } + params.peer_id = bin2hex(params.peer_id) + + if (params.answer) { + if (typeof params.to_peer_id !== 'string' || params.to_peer_id.length !== 20) { + throw new Error('invalid `to_peer_id` (required with `answer`)') + } + params.to_peer_id = bin2hex(params.to_peer_id) + } + + params.left = Number(params.left) + if (Number.isNaN(params.left)) params.left = Infinity + + params.numwant = Math.min( + Number(params.offers && params.offers.length) || 0, // no default - explicit only + common.MAX_ANNOUNCE_PEERS + ) + params.compact = -1 // return full peer objects (used for websocket responses) + } else if (params.action === 'scrape') { + params.action = common.ACTIONS.SCRAPE + + if (typeof params.info_hash === 'string') params.info_hash = [params.info_hash] + if (Array.isArray(params.info_hash)) { + params.info_hash = params.info_hash.map(binaryInfoHash => { + if (typeof binaryInfoHash !== 'string' || binaryInfoHash.length !== 20) { + throw new Error('invalid info_hash') + } + return bin2hex(binaryInfoHash) + }) + } + } else { + throw new Error(`invalid action in WS request: ${params.action}`) + } + + // On first parse, save important data from `socket.upgradeReq` and delete it + // to reduce memory usage. + if (socket.upgradeReq) { + if (opts.trustProxy) { + if (socket.upgradeReq.headers['x-forwarded-for']) { + const [realIp] = socket.upgradeReq.headers['x-forwarded-for'].split(',') + socket.ip = realIp.trim() + } else { + socket.ip = socket.upgradeReq.connection.remoteAddress + } + } else { + socket.ip = socket.upgradeReq.connection.remoteAddress.replace(common.REMOVE_IPV4_MAPPED_IPV6_RE, '') // force ipv4 + } + + socket.port = socket.upgradeReq.connection.remotePort + if (socket.port) { + socket.addr = `${common.IPV6_RE.test(socket.ip) ? `[${socket.ip}]` : socket.ip}:${socket.port}` + } + + socket.headers = socket.upgradeReq.headers + + // Delete `socket.upgradeReq` when it is no longer needed to reduce memory usage + socket.upgradeReq = null + } + + params.ip = socket.ip + params.port = socket.port + params.addr = socket.addr + params.headers = socket.headers + + return params +} diff --git a/lib/server/swarm.js b/lib/server/swarm.js new file mode 100644 index 000000000..9e3a0a204 --- /dev/null +++ b/lib/server/swarm.js @@ -0,0 +1,164 @@ +import arrayRemove from 'unordered-array-remove' +import Debug from 'debug' +import LRU from 'lru' +import randomIterate from 'random-iterate' + +const debug = Debug('bittorrent-tracker:swarm') + +// Regard this as the default implementation of an interface that you +// need to support when overriding Server.createSwarm() and Server.getSwarm() +class Swarm { + constructor (infoHash, server) { + const self = this + self.infoHash = infoHash + self.complete = 0 + self.incomplete = 0 + + self.peers = new LRU({ + max: server.peersCacheLength || 1000, + maxAge: server.peersCacheTtl || 20 * 60 * 1000 // 20 minutes + }) + + // When a peer is evicted from the LRU store, send a synthetic 'stopped' event + // so the stats get updated correctly. + self.peers.on('evict', data => { + const peer = data.value + const params = { + type: peer.type, + event: 'stopped', + numwant: 0, + peer_id: peer.peerId + } + self._onAnnounceStopped(params, peer, peer.peerId) + peer.socket = null + }) + } + + announce (params, cb) { + const self = this + const id = params.type === 'ws' ? params.peer_id : params.addr + // Mark the source peer as recently used in cache + const peer = self.peers.get(id) + + if (params.event === 'started') { + self._onAnnounceStarted(params, peer, id) + } else if (params.event === 'stopped') { + self._onAnnounceStopped(params, peer, id) + if (!cb) return // when websocket is closed + } else if (params.event === 'completed') { + self._onAnnounceCompleted(params, peer, id) + } else if (params.event === 'update') { + self._onAnnounceUpdate(params, peer, id) + } else if (params.event === 'paused') { + self._onAnnouncePaused(params, peer, id) + } else { + cb(new Error('invalid event')) + return + } + cb(null, { + complete: self.complete, + incomplete: self.incomplete, + peers: self._getPeers(params.numwant, params.peer_id, !!params.socket) + }) + } + + scrape (params, cb) { + cb(null, { + complete: this.complete, + incomplete: this.incomplete + }) + } + + _onAnnounceStarted (params, peer, id) { + if (peer) { + debug('unexpected `started` event from peer that is already in swarm') + return this._onAnnounceUpdate(params, peer, id) // treat as an update + } + + if (params.left === 0) this.complete += 1 + else this.incomplete += 1 + this.peers.set(id, { + type: params.type, + complete: params.left === 0, + peerId: params.peer_id, // as hex + ip: params.ip, + port: params.port, + socket: params.socket // only websocket + }) + } + + _onAnnounceStopped (params, peer, id) { + if (!peer) { + debug('unexpected `stopped` event from peer that is not in swarm') + return // do nothing + } + + if (peer.complete) this.complete -= 1 + else this.incomplete -= 1 + + // If it's a websocket, remove this swarm's infohash from the list of active + // swarms that this peer is participating in. + if (peer.socket && !peer.socket.destroyed) { + const index = peer.socket.infoHashes.indexOf(this.infoHash) + arrayRemove(peer.socket.infoHashes, index) + } + + this.peers.remove(id) + } + + _onAnnounceCompleted (params, peer, id) { + if (!peer) { + debug('unexpected `completed` event from peer that is not in swarm') + return this._onAnnounceStarted(params, peer, id) // treat as a start + } + if (peer.complete) { + debug('unexpected `completed` event from peer that is already completed') + return this._onAnnounceUpdate(params, peer, id) // treat as an update + } + + this.complete += 1 + this.incomplete -= 1 + peer.complete = true + this.peers.set(id, peer) + } + + _onAnnounceUpdate (params, peer, id) { + if (!peer) { + debug('unexpected `update` event from peer that is not in swarm') + return this._onAnnounceStarted(params, peer, id) // treat as a start + } + + if (!peer.complete && params.left === 0) { + this.complete += 1 + this.incomplete -= 1 + peer.complete = true + } + this.peers.set(id, peer) + } + + _onAnnouncePaused (params, peer, id) { + if (!peer) { + debug('unexpected `paused` event from peer that is not in swarm') + return this._onAnnounceStarted(params, peer, id) // treat as a start + } + + this._onAnnounceUpdate(params, peer, id) + } + + _getPeers (numwant, ownPeerId, isWebRTC) { + const peers = [] + const ite = randomIterate(this.peers.keys) + let peerId + while ((peerId = ite()) && peers.length < numwant) { + // Don't mark the peer as most recently used on announce + const peer = this.peers.peek(peerId) + if (!peer) continue + if (isWebRTC && peer.peerId === ownPeerId) continue // don't send peer to itself + if ((isWebRTC && peer.type !== 'ws') || (!isWebRTC && peer.type === 'ws')) continue // send proper peer type + peers.push(peer) + } + return peers + } +} + +export default Swarm diff --git a/lib/swarm.js b/lib/swarm.js deleted file mode 100644 index 67fdef75c..000000000 --- a/lib/swarm.js +++ /dev/null @@ -1,109 +0,0 @@ -module.exports = Swarm - -var debug = require('debug')('bittorrent-tracker') -var randomIterate = require('random-iterate') - -// Regard this as the default implementation of an interface that you -// need to support when overriding Server.getSwarm() -function Swarm (infoHash, server) { - this.peers = {} - this.complete = 0 - this.incomplete = 0 -} - -Swarm.prototype.announce = function (params, cb) { - var self = this - var peer = self.peers[params.addr || params.peer_id] - - // Dispatch announce event - var fn = '_onAnnounce_' + params.event - if (self[fn]) { - self[fn](params, peer) // process event - - var peerType = params.compact === undefined ? 'webrtc' : 'addr' - cb(null, { - complete: self.complete, - incomplete: self.incomplete, - peers: self._getPeers(params.numwant, peerType) - }) - } else { - cb(new Error('invalid event')) - } -} - -Swarm.prototype.scrape = function (params, cb) { - cb(null, { - complete: this.complete, - incomplete: this.incomplete - }) -} - -Swarm.prototype._onAnnounce_started = function (params, peer) { - if (peer) { - debug('unexpected `started` event from peer that is already in swarm') - return this._onAnnounce_update(params, peer) // treat as an update - } - - if (params.left === 0) this.complete += 1 - else this.incomplete += 1 - peer = this.peers[params.addr || params.peer_id] = { - complete: params.left === 0, - ip: params.ip, // only http+udp - peerId: params.peer_id, // as hex - port: params.port, // only http+udp - socket: params.socket // only websocket - } -} - -Swarm.prototype._onAnnounce_stopped = function (params, peer) { - if (!peer) { - debug('unexpected `stopped` event from peer that is not in swarm') - return // do nothing - } - - if (peer.complete) this.complete -= 1 - else this.incomplete -= 1 - this.peers[params.addr || params.peer_id] = null -} - -Swarm.prototype._onAnnounce_completed = function (params, peer) { - if (!peer) { - debug('unexpected `completed` event from peer that is not in swarm') - return this._onAnnounce_started(params, peer) // treat as a start - } - if (peer.complete) { - debug('unexpected `completed` event from peer that is already marked as completed') - return // do nothing - } - - this.complete += 1 - this.incomplete -= 1 - peer.complete = true -} - -Swarm.prototype._onAnnounce_update = function (params, peer) { - if (!peer) { - debug('unexpected `update` event from peer that is not in swarm') - return this._onAnnounce_started(params, peer) // treat as a start - } - - if (!peer.complete && params.left === 0) { - this.complete += 1 - this.incomplete -= 1 - peer.complete = true - } -} - -Swarm.prototype._getPeers = function (numWant, peerType) { - var peers = [] - var ite = randomIterate(Object.keys(this.peers)) - while (true) { - var peerId = ite() - if (peers.length >= numWant || peerId == null) return peers - var peer = this.peers[peerId] - if (peer && - ((peerType === 'webrtc' && peer.socket) || (peerType === 'addr' && peer.ip))) { - peers.push(peer) - } - } -} diff --git a/lib/udp-tracker.js b/lib/udp-tracker.js deleted file mode 100644 index e12a79d61..000000000 --- a/lib/udp-tracker.js +++ /dev/null @@ -1,270 +0,0 @@ -module.exports = UDPTracker - -var BN = require('bn.js') -var compact2string = require('compact2string') -var debug = require('debug')('bittorrent-tracker:udp-tracker') -var dgram = require('dgram') -var EventEmitter = require('events').EventEmitter -var hat = require('hat') -var inherits = require('inherits') -var url = require('url') - -var common = require('./common') - -var TIMEOUT = 15000 - -inherits(UDPTracker, EventEmitter) - -/** - * UDP torrent tracker client (for an individual tracker) - * - * @param {Client} client parent bittorrent tracker client - * @param {string} announceUrl announce url of tracker - * @param {Object} opts options object - */ -function UDPTracker (client, announceUrl, opts) { - var self = this - EventEmitter.call(self) - debug('new udp tracker %s', announceUrl) - - self.client = client - self.destroyed = false - - self._opts = opts - self._announceUrl = announceUrl - self._intervalMs = self.client._intervalMs // use client interval initially - self._interval = null - self._cleanupFns = [] -} - -UDPTracker.prototype.announce = function (opts) { - var self = this - if (self.destroyed) return - self._request(opts) -} - -UDPTracker.prototype.scrape = function (opts) { - var self = this - if (self.destroyed) return - opts._scrape = true - self._request(opts) // udp scrape uses same announce url -} - -// TODO: Improve this interface -UDPTracker.prototype.setInterval = function (intervalMs) { - var self = this - clearInterval(self._interval) - - self._intervalMs = intervalMs - if (intervalMs) { - // HACK - var update = self.announce.bind(self, self.client._defaultAnnounceOpts()) - self._interval = setInterval(update, self._intervalMs) - } -} - -UDPTracker.prototype.destroy = function (cb) { - var self = this - if (self.destroyed) return - self.destroyed = true - - self._cleanupFns.slice(0).forEach(function (cleanup) { - cleanup() - }) - self._cleanupFns = [] - cb(null) -} - -UDPTracker.prototype._request = function (opts) { - var self = this - if (!opts) opts = {} - var parsedUrl = url.parse(self._announceUrl) - var transactionId = genTransactionId() - var socket = dgram.createSocket('udp4') - - var cleanup = function () { - if (!socket) return - self._cleanupFns.splice(self._cleanupFns.indexOf(cleanup), 1) - if (timeout) { - clearTimeout(timeout) - timeout = null - } - socket.removeListener('error', onError) - socket.removeListener('message', onSocketMessage) - socket.on('error', noop) // ignore all future errors - try { socket.close() } catch (err) {} - socket = null - } - self._cleanupFns.push(cleanup) - - // does not matter if `stopped` event arrives, so supress errors & cleanup after timeout - var ms = opts.event === 'stopped' ? TIMEOUT / 10 : TIMEOUT - var timeout = setTimeout(function () { - timeout = null - if (opts.event === 'stopped') cleanup() - else onError(new Error('tracker request timed out (' + opts.event + ')')) - }, ms) - if (timeout.unref) timeout.unref() - - send(Buffer.concat([ - common.CONNECTION_ID, - common.toUInt32(common.ACTIONS.CONNECT), - transactionId - ])) - - socket.on('error', onError) - socket.on('message', onSocketMessage) - - function onSocketMessage (msg) { - if (self.destroyed) return - if (msg.length < 8 || msg.readUInt32BE(4) !== transactionId.readUInt32BE(0)) { - return onError(new Error('tracker sent invalid transaction id')) - } - - var action = msg.readUInt32BE(0) - debug('UDP response %s, action %s', self._announceUrl, action) - switch (action) { - case 0: // handshake - if (msg.length < 16) return onError(new Error('invalid udp handshake')) - - if (opts._scrape) scrape(msg.slice(8, 16)) - else announce(msg.slice(8, 16), opts) - - return - - case 1: // announce - cleanup() - if (msg.length < 20) return onError(new Error('invalid announce message')) - - var interval = msg.readUInt32BE(8) - if (interval && !self._opts.interval && self._intervalMs !== 0) { - // use the interval the tracker recommends, UNLESS the user manually specifies an - // interval they want to use - self.setInterval(interval * 1000) - } - - self.client.emit('update', { - announce: self._announceUrl, - complete: msg.readUInt32BE(16), - incomplete: msg.readUInt32BE(12) - }) - - var addrs - try { - addrs = compact2string.multi(msg.slice(20)) - } catch (err) { - return self.client.emit('warning', err) - } - addrs.forEach(function (addr) { - self.client.emit('peer', addr) - }) - break - - case 2: // scrape - cleanup() - if (msg.length < 20 || (msg.length - 8) % 12 !== 0) { - return onError(new Error('invalid scrape message')) - } - var infoHashes = (Array.isArray(opts.infoHash) && opts.infoHash.length > 0) - ? opts.infoHash.map(function (infoHash) { return infoHash.toString('hex') }) - : (opts.infoHash || self.client._infoHash).toString('hex') - - for (var i = 0, len = (msg.length - 8) / 12; i < len; i += 1) { - self.client.emit('scrape', { - announce: self._announceUrl, - infoHash: infoHashes[i], - complete: msg.readUInt32BE(8 + (i * 12)), - downloaded: msg.readUInt32BE(12 + (i * 12)), - incomplete: msg.readUInt32BE(16 + (i * 12)) - }) - } - break - - case 3: // error - cleanup() - if (msg.length < 8) return onError(new Error('invalid error message')) - self.client.emit('warning', new Error(msg.slice(8).toString())) - break - - default: - onError(new Error('tracker sent invalid action')) - break - } - } - - function onError (err) { - if (self.destroyed) return - cleanup() - if (err.message) err.message += ' (' + self._announceUrl + ')' - // errors will often happen if a tracker is offline, so don't treat it as fatal - self.client.emit('warning', err) - } - - function send (message) { - if (!parsedUrl.port) { - parsedUrl.port = 80 - } - socket.send(message, 0, message.length, parsedUrl.port, parsedUrl.hostname) - } - - function announce (connectionId, opts) { - opts = opts || {} - transactionId = genTransactionId() - - send(Buffer.concat([ - connectionId, - common.toUInt32(common.ACTIONS.ANNOUNCE), - transactionId, - self.client._infoHash, - self.client._peerId, - toUInt64(opts.downloaded || 0), - opts.left != null ? toUInt64(opts.left) : new Buffer('FFFFFFFFFFFFFFFF', 'hex'), - toUInt64(opts.uploaded || 0), - common.toUInt32(common.EVENTS[opts.event] || 0), - common.toUInt32(0), // ip address (optional) - common.toUInt32(0), // key (optional) - common.toUInt32(opts.numWant || common.DEFAULT_ANNOUNCE_PEERS), - toUInt16(self.client._port || 0) - ])) - } - - function scrape (connectionId) { - transactionId = genTransactionId() - - var infoHash = (Array.isArray(opts.infoHash) && opts.infoHash.length > 0) - ? Buffer.concat(opts.infoHash) - : (opts.infoHash || self.client._infoHash) - - send(Buffer.concat([ - connectionId, - common.toUInt32(common.ACTIONS.SCRAPE), - transactionId, - infoHash - ])) - } -} - -function genTransactionId () { - return new Buffer(hat(32), 'hex') -} - -function toUInt16 (n) { - var buf = new Buffer(2) - buf.writeUInt16BE(n, 0) - return buf -} - -var MAX_UINT = 4294967295 - -function toUInt64 (n) { - if (n > MAX_UINT || typeof n === 'string') { - var bytes = new BN(n).toArray() - while (bytes.length < 8) { - bytes.unshift(0) - } - return new Buffer(bytes) - } - return Buffer.concat([common.toUInt32(0), common.toUInt32(n)]) -} - -function noop () {} diff --git a/lib/websocket-tracker.js b/lib/websocket-tracker.js deleted file mode 100644 index b5233b47f..000000000 --- a/lib/websocket-tracker.js +++ /dev/null @@ -1,290 +0,0 @@ -// TODO: cleanup unused Peers when tracker doesn't respond with matches - -module.exports = WebSocketTracker - -var debug = require('debug')('bittorrent-tracker:websocket-tracker') -var EventEmitter = require('events').EventEmitter -var hat = require('hat') -var inherits = require('inherits') -var Peer = require('simple-peer') -var Socket = require('simple-websocket') - -var common = require('./common') - -// Use a socket pool, so tracker clients share WebSocket objects for the same server. -// In practice, WebSockets are pretty slow to establish, so this gives a nice performance -// boost, and saves browser resources. -var socketPool = {} - -var RECONNECT_VARIANCE = 30 * 1000 -var RECONNECT_MINIMUM = 5 * 1000 - -inherits(WebSocketTracker, EventEmitter) - -function WebSocketTracker (client, announceUrl, opts) { - var self = this - EventEmitter.call(self) - debug('new websocket tracker %s', announceUrl) - - self.client = client - self.destroyed = false - - self._opts = opts - - if (announceUrl[announceUrl.length - 1] === '/') { - announceUrl = announceUrl.substring(0, announceUrl.length - 1) - } - self._announceUrl = announceUrl - - self._peers = {} // peers (offer id -> peer) - self._socket = null - self._intervalMs = self.client._intervalMs // use client interval initially - self._interval = null - - self._openSocket() -} - -WebSocketTracker.prototype.announce = function (opts) { - var self = this - if (self.destroyed) return - if (!self._socket.connected) { - return self._socket.once('connect', self.announce.bind(self, opts)) - } - - opts.info_hash = self.client._infoHashBinary - opts.peer_id = self.client._peerIdBinary - - // Limit number of offers (temporarily) - // TODO: remove this when we cleanup old RTCPeerConnections cleanly - if (opts.numWant > 10) opts.numWant = 10 - - self._generateOffers(opts.numWant, function (offers) { - opts.offers = offers - - if (self._trackerId) { - opts.trackerid = self._trackerId - } - self._send(opts) - }) -} - -WebSocketTracker.prototype.scrape = function (opts) { - var self = this - if (self.destroyed) return - self._onSocketError(new Error('scrape not supported ' + self._announceUrl)) -} - -// TODO: Improve this interface -WebSocketTracker.prototype.setInterval = function (intervalMs) { - var self = this - clearInterval(self._interval) - - self._intervalMs = intervalMs - if (intervalMs) { - // HACK - var update = self.announce.bind(self, self.client._defaultAnnounceOpts()) - self._interval = setInterval(update, self._intervalMs) - } -} - -WebSocketTracker.prototype.destroy = function (onclose) { - var self = this - if (self.destroyed) return - self.destroyed = true - - self._socket.removeListener('data', self._onSocketDataBound) - self._socket.removeListener('close', self._onSocketCloseBound) - self._socket.removeListener('error', self._onSocketErrorBound) - - self._onSocketErrorBound = null - self._onSocketDataBound = null - self._onSocketCloseBound = null - - self._socket.on('error', noop) // ignore all future errors - try { - self._socket.destroy(onclose) - } catch (err) { - if (onclose) onclose() - } - - self._socket = null -} - -WebSocketTracker.prototype._openSocket = function () { - var self = this - self._onSocketErrorBound = self._onSocketError.bind(self) - self._onSocketDataBound = self._onSocketData.bind(self) - self._onSocketCloseBound = self._onSocketClose.bind(self) - - self._socket = socketPool[self._announceUrl] - if (!self._socket) { - self._socket = socketPool[self._announceUrl] = new Socket(self._announceUrl) - } - - self._socket.on('data', self._onSocketDataBound) - self._socket.on('close', self._onSocketCloseBound) - self._socket.on('error', self._onSocketErrorBound) -} - -WebSocketTracker.prototype._onSocketData = function (data) { - var self = this - if (self.destroyed) return - - if (!(typeof data === 'object' && data !== null)) { - return self.client.emit('warning', new Error('Invalid tracker response')) - } - - if (data.info_hash !== self.client._infoHashBinary) { - debug( - 'ignoring websocket data from %s for %s (looking for %s: reused socket)', - self._announceUrl, common.binaryToHex(data.info_hash), self.client._infoHashHex - ) - return - } - - if (data.peer_id && data.peer_id === self.client._peerIdBinary) { - // ignore offers/answers from this client - return - } - - debug( - 'received %s from %s for %s', - JSON.stringify(data), self._announceUrl, self.client._infoHashHex - ) - - var failure = data['failure reason'] - if (failure) return self.client.emit('warning', new Error(failure)) - - var warning = data['warning message'] - if (warning) self.client.emit('warning', new Error(warning)) - - var interval = data.interval || data['min interval'] - if (interval && !self._opts.interval && self._intervalMs !== 0) { - // use the interval the tracker recommends, UNLESS the user manually specifies an - // interval they want to use - self.setInterval(interval * 1000) - } - - var trackerId = data['tracker id'] - if (trackerId) { - // If absent, do not discard previous trackerId value - self._trackerId = trackerId - } - - if (data.complete) { - self.client.emit('update', { - announce: self._announceUrl, - complete: data.complete, - incomplete: data.incomplete - }) - } - - var peer - if (data.offer && data.peer_id) { - peer = new Peer({ - trickle: false, - config: self.client._rtcConfig, - wrtc: self.client._wrtc - }) - peer.id = common.binaryToHex(data.peer_id) - peer.once('signal', function (answer) { - var params = { - info_hash: self.client._infoHashBinary, - peer_id: self.client._peerIdBinary, - to_peer_id: data.peer_id, - answer: answer, - offer_id: data.offer_id - } - if (self._trackerId) params.trackerid = self._trackerId - self._send(params) - }) - peer.signal(data.offer) - self.client.emit('peer', peer) - } - - if (data.answer && data.peer_id) { - peer = self._peers[common.binaryToHex(data.offer_id)] - if (peer) { - peer.id = common.binaryToHex(data.peer_id) - peer.signal(data.answer) - self.client.emit('peer', peer) - } else { - debug('got unexpected answer: ' + JSON.stringify(data.answer)) - } - } -} - -WebSocketTracker.prototype._onSocketClose = function () { - var self = this - if (self.destroyed) return - self.destroy() - self._startReconnectTimer() -} - -WebSocketTracker.prototype._onSocketError = function (err) { - var self = this - if (self.destroyed) return - self.destroy() - // errors will often happen if a tracker is offline, so don't treat it as fatal - self.client.emit('warning', err) - self._startReconnectTimer() -} - -WebSocketTracker.prototype._startReconnectTimer = function () { - var self = this - var ms = Math.floor(Math.random() * RECONNECT_VARIANCE) + RECONNECT_MINIMUM - - var reconnectTimer = setTimeout(function () { - self.destroyed = false - self._openSocket() - }, ms) - if (reconnectTimer.unref) reconnectTimer.unref() - - debug('reconnecting socket in %s ms', ms) -} - -WebSocketTracker.prototype._send = function (params) { - var self = this - if (self.destroyed) return - - var message = JSON.stringify(params) - debug('send %s', message) - self._socket.send(message) -} - -WebSocketTracker.prototype._generateOffers = function (numWant, cb) { - var self = this - var offers = [] - debug('generating %s offers', numWant) - - // TODO: cleanup dead peers and peers that never get a return offer, from self._peers - for (var i = 0; i < numWant; ++i) { - generateOffer() - } - - function generateOffer () { - var offerId = hat(160) - var peer = self._peers[offerId] = new Peer({ - initiator: true, - trickle: false, - config: self.client._rtcConfig, - wrtc: self.client._wrtc - }) - peer.once('signal', function (offer) { - offers.push({ - offer: offer, - offer_id: common.hexToBinary(offerId) - }) - checkDone() - }) - } - - function checkDone () { - if (offers.length === numWant) { - debug('generated %s offers', numWant) - cb(offers) - } - } -} - -function noop () {} diff --git a/package.json b/package.json index 9acf59ade..ca9cebd6d 100644 --- a/package.json +++ b/package.json @@ -1,69 +1,127 @@ { "name": "bittorrent-tracker", "description": "Simple, robust, BitTorrent tracker (client & server) implementation", - "version": "5.2.0", + "version": "11.2.2", "author": { - "name": "Feross Aboukhadijeh", - "email": "feross@feross.org", - "url": "http://feross.org/" + "name": "WebTorrent LLC", + "email": "feross@webtorrent.io", + "url": "https://webtorrent.io" }, "bin": { "bittorrent-tracker": "./bin/cmd.js" }, "browser": { - "./lib/common-node": false, - "./lib/http-tracker": false, - "./lib/udp-tracker": false, - "./server": false + "./lib/common-node.js": false, + "./lib/client/http-tracker.js": false, + "./lib/client/udp-tracker.js": false, + "./server.js": false, + "socks": false + }, + "chromeapp": { + "./server.js": false, + "dgram": "chrome-dgram", + "socks": false }, "bugs": { - "url": "https://github.com/feross/bittorrent-tracker/issues" + "url": "https://github.com/webtorrent/bittorrent-tracker/issues" }, + "type": "module", "dependencies": { - "bencode": "^0.7.0", - "bn.js": "^3.0.1", - "buffer-equal": "0.0.1", - "compact2string": "^1.2.0", - "debug": "^2.0.0", - "hat": "0.0.3", - "inherits": "^2.0.1", - "ip": "^0.3.0", - "minimist": "^1.1.1", - "once": "^1.3.0", + "@thaunknown/simple-peer": "^10.0.8", + "@thaunknown/simple-websocket": "^9.1.3", + "bencode": "^4.0.0", + "bittorrent-peerid": "^1.3.6", + "chrome-dgram": "^3.0.6", + "compact2string": "^1.4.1", + "cross-fetch-ponyfill": "^1.0.3", + "debug": "^4.3.4", + "ip": "^2.0.1", + "lru": "^3.1.0", + "minimist": "^1.2.8", + "once": "^1.4.0", + "queue-microtask": "^1.2.3", "random-iterate": "^1.0.1", - "run-parallel": "^1.1.2", - "run-series": "^1.0.2", - "simple-get": "^1.3.0", - "simple-peer": "^5.0.0", - "simple-websocket": "^2.0.0", - "string2compact": "^1.1.1", - "ws": "^0.7.1", - "xtend": "^4.0.0" + "run-parallel": "^1.2.0", + "run-series": "^1.1.9", + "socks": "^2.8.3", + "string2compact": "^2.0.1", + "uint8-util": "^2.2.5", + "unordered-array-remove": "^1.0.2", + "ws": "^8.17.0" }, "devDependencies": { - "magnet-uri": "^5.0.0", - "parse-torrent": "^5.0.0", - "standard": "^4.3.2", - "tape": "^4.0.0" + "@mapbox/node-pre-gyp": "1.0.11", + "@webtorrent/semantic-release-config": "1.0.10", + "magnet-uri": "7.0.7", + "semantic-release": "21.1.2", + "standard": "*", + "tape": "5.9.0", + "undici": "^6.16.1", + "webrtc-polyfill": "^1.1.5", + "webtorrent-fixtures": "2.0.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "exports": { + ".": { + "import": "./index.js" + }, + "./client": { + "import": "./client.js" + }, + "./server": { + "import": "./server.js" + }, + "./websocket-tracker": { + "import": "./lib/client/websocket-tracker.js" + } }, - "homepage": "http://webtorrent.io", "keywords": [ - "torrent", "bittorrent", - "tracker", - "stream", - "peer", - "wire", "p2p", - "peer-to-peer" + "peer", + "peer-to-peer", + "stream", + "torrent", + "tracker", + "wire" ], "license": "MIT", "main": "index.js", + "optionalDependencies": { + "bufferutil": "^4.0.8", + "utf-8-validate": "^6.0.4" + }, "repository": { "type": "git", - "url": "git://github.com/feross/bittorrent-tracker.git" + "url": "git://github.com/webtorrent/bittorrent-tracker.git" }, "scripts": { - "test": "standard && tape test/*.js" + "preversion": "npm run update-authors", + "test": "standard && tape test/*.js", + "update-authors": "./tools/update-authors.sh" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "renovate": { + "extends": [ + "github>webtorrent/renovate-config" + ] + }, + "release": { + "extends": "@webtorrent/semantic-release-config" } } diff --git a/server.js b/server.js index 5287cdf30..f7023d99f 100644 --- a/server.js +++ b/server.js @@ -1,22 +1,22 @@ -module.exports = Server - -var bencode = require('bencode') -var debug = require('debug')('bittorrent-tracker') -var dgram = require('dgram') -var EventEmitter = require('events').EventEmitter -var http = require('http') -var inherits = require('inherits') -var series = require('run-series') -var string2compact = require('string2compact') -var WebSocketServer = require('ws').Server - -var common = require('./lib/common') -var Swarm = require('./lib/swarm') -var parseHttpRequest = require('./lib/parse_http') -var parseUdpRequest = require('./lib/parse_udp') -var parseWebSocketRequest = require('./lib/parse_websocket') - -inherits(Server, EventEmitter) +import bencode from 'bencode' +import Debug from 'debug' +import dgram from 'dgram' +import EventEmitter from 'events' +import http from 'http' +import peerid from 'bittorrent-peerid' +import series from 'run-series' +import string2compact from 'string2compact' +import { WebSocketServer } from 'ws' +import { hex2bin } from 'uint8-util' + +import common from './lib/common.js' +import Swarm from './lib/server/swarm.js' +import parseHttpRequest from './lib/server/parse-http.js' +import parseUdpRequest from './lib/server/parse-udp.js' +import parseWebSocketRequest from './lib/server/parse-websocket.js' + +const debug = Debug('bittorrent-tracker:server') +const hasOwnProperty = Object.prototype.hasOwnProperty /** * BitTorrent tracker server. @@ -25,479 +25,737 @@ inherits(Server, EventEmitter) * metrics from clients that help the tracker keep overall statistics about the torrent. * Responses include a peer list that helps the client participate in the torrent. * - * @param {Object} opts options object - * @param {Number} opts.interval tell clients to announce on this interval (ms) - * @param {Number} opts.trustProxy trust 'x-forwarded-for' header from reverse proxy - * @param {boolean} opts.http start an http server? (default: true) - * @param {boolean} opts.udp start a udp server? (default: true) - * @param {boolean} opts.ws start a websocket server? (default: true) - * @param {function} opts.filter black/whitelist fn for disallowing/allowing torrents + * @param {Object} opts options object + * @param {Number} opts.interval tell clients to announce on this interval (ms) + * @param {Number} opts.trustProxy trust 'x-forwarded-for' header from reverse proxy + * @param {boolean|Object} opts.http start an http server?, or options for http.createServer (default: true) + * @param {boolean|Object} opts.udp start a udp server?, or extra options for dgram.createSocket (default: true) + * @param {boolean|Object} opts.ws start a websocket server?, or extra options for new WebSocketServer (default: true) + * @param {boolean} opts.stats enable web-based statistics? (default: true) + * @param {function} opts.filter black/whitelist fn for disallowing/allowing torrents */ -function Server (opts) { - var self = this - if (!(self instanceof Server)) return new Server(opts) - EventEmitter.call(self) - if (!opts) opts = {} - - debug('new server %s', JSON.stringify(opts)) - - self._intervalMs = opts.interval - ? opts.interval - : 10 * 60 * 1000 // 10 min - - self._trustProxy = !!opts.trustProxy - if (typeof opts.filter === 'function') self._filter = opts.filter - - self._listenCalled = false - self.listening = false - self.destroyed = false - self.torrents = {} - - self.http = null - self.udp4 = null - self.udp6 = null - self.ws = null - - // start an http tracker unless the user explictly says no - if (opts.http !== false) { - self.http = http.createServer() - self.http.on('request', function (req, res) { self.onHttpRequest(req, res) }) - self.http.on('error', function (err) { self._onError(err) }) - self.http.on('listening', onListening) - } - - // start a udp tracker unless the user explicitly says no - if (opts.udp !== false) { - var isNode10 = /^v0.10./.test(process.version) - - self.udp4 = self.udp = dgram.createSocket( - isNode10 ? 'udp4' : { type: 'udp4', reuseAddr: true } - ) - self.udp4.on('message', function (msg, rinfo) { self.onUdpRequest(msg, rinfo) }) - self.udp4.on('error', function (err) { self._onError(err) }) - self.udp4.on('listening', onListening) - - self.udp6 = dgram.createSocket( - isNode10 ? 'udp6' : { type: 'udp6', reuseAddr: true } - ) - self.udp6.on('message', function (msg, rinfo) { self.onUdpRequest(msg, rinfo) }) - self.udp6.on('error', function (err) { self._onError(err) }) - self.udp6.on('listening', onListening) - } - - // start a websocket tracker (for WebTorrent) unless the user explicitly says no - if (opts.ws !== false) { - if (!self.http) { - self.http = http.createServer() - self.http.on('request', function (req, res) { - res.statusCode = 404 - res.end('404 Not Found') +class Server extends EventEmitter { + constructor (opts = {}) { + super() + debug('new server %s', JSON.stringify(opts)) + + this.intervalMs = opts.interval + ? opts.interval + : 10 * 60 * 1000 // 10 min + + this._trustProxy = !!opts.trustProxy + if (typeof opts.filter === 'function') this._filter = opts.filter + + this.peersCacheLength = opts.peersCacheLength + this.peersCacheTtl = opts.peersCacheTtl + + this._listenCalled = false + this.listening = false + this.destroyed = false + this.torrents = {} + + this.http = null + this.udp4 = null + this.udp6 = null + this.ws = null + + // start an http tracker unless the user explictly says no + if (opts.http !== false) { + this.http = http.createServer(isObject(opts.http) ? opts.http : undefined) + this.http.on('error', err => { this._onError(err) }) + this.http.on('listening', onListening) + + // Add default http request handler on next tick to give user the chance to add + // their own handler first. Handle requests untouched by user's handler. + process.nextTick(() => { + this.http.on('request', (req, res) => { + if (res.headersSent) return + this.onHttpRequest(req, res) + }) }) - self.http.on('error', function (err) { self._onError(err) }) - self.http.on('listening', onListening) } - self.ws = new WebSocketServer({ server: self.http }) - self.ws.address = self.http.address.bind(self.http) - self.ws.on('error', function (err) { self._onError(err) }) - self.ws.on('connection', function (socket) { self.onWebSocketConnection(socket) }) - } - var num = !!self.http + !!self.udp4 + !!self.udp6 - function onListening () { - num -= 1 - if (num === 0) { - self.listening = true - debug('listening') - self.emit('listening') + // start a udp tracker unless the user explicitly says no + if (opts.udp !== false) { + this.udp4 = this.udp = dgram.createSocket({ + type: 'udp4', + reuseAddr: true, + ...(isObject(opts.udp) ? opts.udp : undefined) + }) + this.udp4.on('message', (msg, rinfo) => { this.onUdpRequest(msg, rinfo) }) + this.udp4.on('error', err => { this._onError(err) }) + this.udp4.on('listening', onListening) + + this.udp6 = dgram.createSocket({ + type: 'udp6', + reuseAddr: true, + ...(isObject(opts.udp) ? opts.udp : undefined) + }) + this.udp6.on('message', (msg, rinfo) => { this.onUdpRequest(msg, rinfo) }) + this.udp6.on('error', err => { this._onError(err) }) + this.udp6.on('listening', onListening) } - } -} -Server.prototype._onError = function (err) { - var self = this - self.emit('error', err) -} + // start a websocket tracker (for WebTorrent) unless the user explicitly says no + if (opts.ws !== false) { + const noServer = isObject(opts.ws) && opts.ws.noServer + if (!this.http && !noServer) { + this.http = http.createServer() + this.http.on('error', err => { this._onError(err) }) + this.http.on('listening', onListening) + + // Add default http request handler on next tick to give user the chance to add + // their own handler first. Handle requests untouched by user's handler. + process.nextTick(() => { + this.http.on('request', (req, res) => { + if (res.headersSent) return + // For websocket trackers, we only need to handle the UPGRADE http method. + // Return 404 for all other request types. + res.statusCode = 404 + res.end('404 Not Found') + }) + }) + } + this.ws = new WebSocketServer({ + server: noServer ? undefined : this.http, + perMessageDeflate: false, + clientTracking: false, + ...(isObject(opts.ws) ? opts.ws : undefined) + }) -Server.prototype.listen = function (/* port, hostname, onlistening */) { - var self = this + this.ws.address = () => { + if (noServer) { + throw new Error('address() unavailable with { noServer: true }') + } + return this.http.address() + } - if (self._listenCalled || self.listening) throw new Error('server already listening') - self._listenCalled = true + this.ws.on('error', err => { this._onError(err) }) + this.ws.on('connection', (socket, req) => { + // Note: socket.upgradeReq was removed in ws@3.0.0, so re-add it. + // https://github.com/websockets/ws/pull/1099 + socket.upgradeReq = req + this.onWebSocketConnection(socket) + }) + } - var lastArg = arguments[arguments.length - 1] - if (typeof lastArg === 'function') self.once('listening', lastArg) + if (opts.stats !== false) { + if (!this.http) { + this.http = http.createServer() + this.http.on('error', err => { this._onError(err) }) + this.http.on('listening', onListening) + } - var port = toNumber(arguments[0]) || arguments[0] || 0 - var hostname = typeof arguments[1] !== 'function' ? arguments[1] : undefined + // Http handler for '/stats' route + this.http.on('request', (req, res) => { + if (res.headersSent) return - debug('listen %o %o', port, hostname) + const infoHashes = Object.keys(this.torrents) + let activeTorrents = 0 + const allPeers = {} - function isObject (obj) { - return typeof obj === 'object' && obj !== null - } + function countPeers (filterFunction) { + let count = 0 + let key - var httpPort = isObject(port) ? (port.http || 0) : port - var udpPort = isObject(port) ? (port.udp || 0) : port + for (key in allPeers) { + if (hasOwnProperty.call(allPeers, key) && filterFunction(allPeers[key])) { + count++ + } + } - // binding to :: only receives IPv4 connections if the bindv6only sysctl is set 0, - // which is the default on many operating systems - var httpHostname = isObject(hostname) ? hostname.http : (hostname || '::') - var udp4Hostname = isObject(hostname) ? hostname.udp : hostname - var udp6Hostname = isObject(hostname) ? hostname.udp6 : hostname + return count + } - if (self.http) self.http.listen(httpPort, httpHostname) - if (self.udp4) self.udp4.bind(udpPort, udp4Hostname) - if (self.udp6) self.udp6.bind(udpPort, udp6Hostname) -} + function groupByClient () { + const clients = {} + for (const key in allPeers) { + if (hasOwnProperty.call(allPeers, key)) { + const peer = allPeers[key] + + if (!clients[peer.client.client]) { + clients[peer.client.client] = {} + } + const client = clients[peer.client.client] + // If the client is not known show 8 chars from peerId as version + const version = peer.client.version || Buffer.from(peer.peerId, 'hex').toString().substring(0, 8) + if (!client[version]) { + client[version] = 0 + } + client[version]++ + } + } + return clients + } -Server.prototype.close = function (cb) { - var self = this - if (!cb) cb = function () {} - debug('close') + function printClients (clients) { + let html = '
    \n' + for (const name in clients) { + if (hasOwnProperty.call(clients, name)) { + const client = clients[name] + for (const version in client) { + if (hasOwnProperty.call(client, version)) { + html += `
  • ${name} ${version} : ${client[version]}
  • \n` + } + } + } + } + html += '
' + return html + } - self.listening = false - self.destroyed = true + if (req.method === 'GET' && (req.url === '/stats' || req.url === '/stats.json')) { + infoHashes.forEach(infoHash => { + const peers = this.torrents[infoHash].peers + const keys = peers.keys + if (keys.length > 0) activeTorrents++ + + keys.forEach(peerId => { + // Don't mark the peer as most recently used for stats + const peer = peers.peek(peerId) + if (peer == null) return // peers.peek() can evict the peer + + if (!hasOwnProperty.call(allPeers, peerId)) { + allPeers[peerId] = { + ipv4: false, + ipv6: false, + seeder: false, + leecher: false + } + } + + if (peer.ip.includes(':')) { + allPeers[peerId].ipv6 = true + } else { + allPeers[peerId].ipv4 = true + } + + if (peer.complete) { + allPeers[peerId].seeder = true + } else { + allPeers[peerId].leecher = true + } + + allPeers[peerId].peerId = peer.peerId + allPeers[peerId].client = peerid(peer.peerId) + }) + }) - if (self.udp4) { - try { - self.udp4.close() - } catch (err) {} - } + const isSeederOnly = peer => peer.seeder && peer.leecher === false + const isLeecherOnly = peer => peer.leecher && peer.seeder === false + const isSeederAndLeecher = peer => peer.seeder && peer.leecher + const isIPv4 = peer => peer.ipv4 + const isIPv6 = peer => peer.ipv6 + + const stats = { + torrents: infoHashes.length, + activeTorrents, + peersAll: Object.keys(allPeers).length, + peersSeederOnly: countPeers(isSeederOnly), + peersLeecherOnly: countPeers(isLeecherOnly), + peersSeederAndLeecher: countPeers(isSeederAndLeecher), + peersIPv4: countPeers(isIPv4), + peersIPv6: countPeers(isIPv6), + clients: groupByClient() + } - if (self.udp6) { - try { - self.udp6.close() - } catch (err) {} + if (req.url === '/stats.json' || req.headers.accept === 'application/json') { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(stats)) + } else if (req.url === '/stats') { + res.setHeader('Content-Type', 'text/html') + res.end(` +

${stats.torrents} torrents (${stats.activeTorrents} active)

+

Connected Peers: ${stats.peersAll}

+

Peers Seeding Only: ${stats.peersSeederOnly}

+

Peers Leeching Only: ${stats.peersLeecherOnly}

+

Peers Seeding & Leeching: ${stats.peersSeederAndLeecher}

+

IPv4 Peers: ${stats.peersIPv4}

+

IPv6 Peers: ${stats.peersIPv6}

+

Clients:

+ ${printClients(stats.clients)} + `.replace(/^\s+/gm, '')) // trim left + } + } + }) + } + + let num = !!this.http + !!this.udp4 + !!this.udp6 + const self = this + function onListening () { + num -= 1 + if (num === 0) { + self.listening = true + debug('listening') + self.emit('listening') + } + } } - if (self.ws) { - try { - self.ws.close() - } catch (err) {} + _onError (err) { + this.emit('error', err) } - if (self.http) self.http.close(cb) - else cb(null) -} + listen (...args) /* port, hostname, onlistening */{ + if (this._listenCalled || this.listening) throw new Error('server already listening') + this._listenCalled = true -Server.prototype.createSwarm = function (infoHash) { - var self = this - if (Buffer.isBuffer(infoHash)) infoHash = infoHash.toString('hex') - var swarm = self.torrents[infoHash] = new Swarm(infoHash, self) - return swarm -} + const lastArg = args[args.length - 1] + if (typeof lastArg === 'function') this.once('listening', lastArg) -Server.prototype.getSwarm = function (infoHash) { - var self = this - if (Buffer.isBuffer(infoHash)) infoHash = infoHash.toString('hex') - return self.torrents[infoHash] -} + const port = toNumber(args[0]) || args[0] || 0 + const hostname = typeof args[1] !== 'function' ? args[1] : undefined + + debug('listen (port: %o hostname: %o)', port, hostname) -Server.prototype.onHttpRequest = function (req, res, opts) { - var self = this - if (!opts) opts = {} - opts.trustProxy = opts.trustProxy || self._trustProxy - - var params - try { - params = parseHttpRequest(req, opts) - params.httpReq = req - params.httpRes = res - } catch (err) { - res.end(bencode.encode({ - 'failure reason': err.message - })) - - // even though it's an error for the client, it's just a warning for the server. - // don't crash the server because a client sent bad data :) - self.emit('warning', err) - return + const httpPort = isObject(port) ? (port.http || 0) : port + const udpPort = isObject(port) ? (port.udp || 0) : port + + // binding to :: only receives IPv4 connections if the bindv6only sysctl is set 0, + // which is the default on many operating systems + const httpHostname = isObject(hostname) ? hostname.http : hostname + const udp4Hostname = isObject(hostname) ? hostname.udp : hostname + const udp6Hostname = isObject(hostname) ? hostname.udp6 : hostname + + if (this.http) this.http.listen(httpPort, httpHostname) + if (this.udp4) this.udp4.bind(udpPort, udp4Hostname) + if (this.udp6) this.udp6.bind(udpPort, udp6Hostname) } - self._onRequest(params, function (err, response) { - if (err) { - self.emit('warning', err) - response = { - 'failure reason': err.message - } + close (cb = noop) { + debug('close') + + this.listening = false + this.destroyed = true + + if (this.udp4) { + try { + this.udp4.close() + } catch (err) {} } - if (self.destroyed) return - delete response.action // only needed for UDP encoding - res.end(bencode.encode(response)) + if (this.udp6) { + try { + this.udp6.close() + } catch (err) {} + } - if (params.action === common.ACTIONS.ANNOUNCE) { - self.emit(common.EVENT_NAMES[params.event], params.addr, params) + if (this.ws) { + try { + this.ws.close() + } catch (err) {} } - }) -} -Server.prototype.onUdpRequest = function (msg, rinfo) { - var self = this + if (this.http) this.http.close(cb) + else cb(null) + } + + createSwarm (infoHash, cb) { + if (ArrayBuffer.isView(infoHash)) infoHash = infoHash.toString('hex') + + process.nextTick(() => { + const swarm = this.torrents[infoHash] = new Server.Swarm(infoHash, this) + cb(null, swarm) + }) + } + + getSwarm (infoHash, cb) { + if (ArrayBuffer.isView(infoHash)) infoHash = infoHash.toString('hex') - var params - try { - params = parseUdpRequest(msg, rinfo) - } catch (err) { - self.emit('warning', err) - // Do not reply for parsing errors - return + process.nextTick(() => { + cb(null, this.torrents[infoHash]) + }) } - self._onRequest(params, function (err, response) { - if (err) { - self.emit('warning', err) - response = { - action: common.ACTIONS.ERROR, + onHttpRequest (req, res, opts = {}) { + opts.trustProxy = opts.trustProxy || this._trustProxy + + let params + try { + params = parseHttpRequest(req, opts) + params.httpReq = req + params.httpRes = res + } catch (err) { + res.end(bencode.encode({ 'failure reason': err.message - } + })) + + // even though it's an error for the client, it's just a warning for the server. + // don't crash the server because a client sent bad data :) + this.emit('warning', err) + return } - if (self.destroyed) return - response.transactionId = params.transactionId - response.connectionId = params.connectionId + this._onRequest(params, (err, response) => { + if (err) { + this.emit('warning', err) + response = { + 'failure reason': err.message + } + } + if (this.destroyed) return res.end() + + delete response.action // only needed for UDP encoding + res.end(bencode.encode(response)) - var buf = makeUdpPacket(response) + if (params.action === common.ACTIONS.ANNOUNCE) { + this.emit(common.EVENT_NAMES[params.event], params.addr, params) + } + }) + } + onUdpRequest (msg, rinfo) { + let params try { - var udp = (rinfo.family === 'IPv4') ? self.udp4 : self.udp6 - udp.send(buf, 0, buf.length, rinfo.port, rinfo.address) + params = parseUdpRequest(msg, rinfo) } catch (err) { - self.emit('warning', err) + this.emit('warning', err) + // Do not reply for parsing errors + return } - if (params.action === common.ACTIONS.ANNOUNCE) { - self.emit(common.EVENT_NAMES[params.event], params.addr, params) - } - }) -} + this._onRequest(params, (err, response) => { + if (err) { + this.emit('warning', err) + response = { + action: common.ACTIONS.ERROR, + 'failure reason': err.message + } + } + if (this.destroyed) return -Server.prototype.onWebSocketConnection = function (socket) { - var self = this - socket.peerId = null // as hex - socket.infoHashes = [] - socket.onSend = self._onWebSocketSend.bind(self, socket) - socket.on('message', self._onWebSocketRequest.bind(self, socket)) - socket.on('error', self._onWebSocketError.bind(self, socket)) - socket.on('close', self._onWebSocketClose.bind(self, socket)) -} + response.transactionId = params.transactionId + response.connectionId = params.connectionId -Server.prototype._onWebSocketRequest = function (socket, params) { - var self = this - - try { - params = parseWebSocketRequest(socket, params) - } catch (err) { - socket.send(JSON.stringify({ - 'failure reason': err.message, - info_hash: common.hexToBinary(params.info_hash) - }), socket.onSend) - - // even though it's an error for the client, it's just a warning for the server. - // don't crash the server because a client sent bad data :) - self.emit('warning', err) - return - } + const buf = makeUdpPacket(response) - if (!socket.peerId) socket.peerId = params.peer_id // as hex + try { + const udp = (rinfo.family === 'IPv4') ? this.udp4 : this.udp6 + udp.send(buf, 0, buf.length, rinfo.port, rinfo.address) + } catch (err) { + this.emit('warning', err) + } - self._onRequest(params, function (err, response) { - if (err) { - self.emit('warning', err) - response = { - 'failure reason': err.message + if (params.action === common.ACTIONS.ANNOUNCE) { + this.emit(common.EVENT_NAMES[params.event], params.addr, params) } + }) + } + + onWebSocketConnection (socket, opts = {}) { + opts.trustProxy = opts.trustProxy || this._trustProxy + + socket.peerId = null // as hex + socket.infoHashes = [] // swarms that this socket is participating in + socket.onSend = err => { + this._onWebSocketSend(socket, err) } - if (self.destroyed) return - if (socket.infoHashes.indexOf(params.info_hash) === -1) { - socket.infoHashes.push(params.info_hash) + socket.onMessageBound = params => { + this._onWebSocketRequest(socket, opts, params) } + socket.on('message', socket.onMessageBound) - var peers = response.peers - delete response.peers - response.interval = self._intervalMs - response.info_hash = common.hexToBinary(params.info_hash) - - socket.send(JSON.stringify(response), socket.onSend) - debug('sent response %s to %s', JSON.stringify(response), params.peer_id) - - if (params.numwant) { - debug('got offers %s from %s', JSON.stringify(params.offers), params.peer_id) - debug('got %s peers from swarm %s', peers.length, params.info_hash) - peers.forEach(function (peer, i) { - peer.socket.send(JSON.stringify({ - offer: params.offers[i].offer, - offer_id: params.offers[i].offer_id, - peer_id: common.hexToBinary(params.peer_id), - info_hash: common.hexToBinary(params.info_hash) - })) - debug('sent offer to %s from %s', peer.peerId, params.peer_id) - }) + socket.onErrorBound = err => { + this._onWebSocketError(socket, err) } + socket.on('error', socket.onErrorBound) - if (params.answer) { - debug('got answer %s from %s', JSON.stringify(params.answer), params.peer_id) + socket.onCloseBound = () => { + this._onWebSocketClose(socket) + } + socket.on('close', socket.onCloseBound) + } - var swarm = self.getSwarm(params.info_hash) - if (!swarm) { - return self.emit('warning', new Error('no swarm with that `info_hash`')) - } - var toPeer = swarm.peers[params.to_peer_id] - if (!toPeer) { - return self.emit('warning', new Error('no peer with that `to_peer_id`')) - } + _onWebSocketRequest (socket, opts, params) { + try { + params = parseWebSocketRequest(socket, opts, params) + } catch (err) { + socket.send(JSON.stringify({ + 'failure reason': err.message + }), socket.onSend) - toPeer.socket.send(JSON.stringify({ - answer: params.answer, - offer_id: params.offer_id, - peer_id: common.hexToBinary(params.peer_id), - info_hash: common.hexToBinary(params.info_hash) - })) - debug('sent answer to %s from %s', toPeer.peerId, params.peer_id) + // even though it's an error for the client, it's just a warning for the server. + // don't crash the server because a client sent bad data :) + this.emit('warning', err) + return } - if (params.action === common.ACTIONS.ANNOUNCE) { - self.emit(common.EVENT_NAMES[params.event], params.peer_id, params) - } - }) -} + if (!socket.peerId) socket.peerId = params.peer_id // as hex -Server.prototype._onRequest = function (params, cb) { - var self = this - if (params && params.action === common.ACTIONS.CONNECT) { - cb(null, { action: common.ACTIONS.CONNECT }) - } else if (params && params.action === common.ACTIONS.ANNOUNCE) { - self._onAnnounce(params, cb) - } else if (params && params.action === common.ACTIONS.SCRAPE) { - self._onScrape(params, cb) - } else { - cb(new Error('Invalid action')) - } -} + this._onRequest(params, (err, response) => { + if (this.destroyed || socket.destroyed) return + if (err) { + socket.send(JSON.stringify({ + action: params.action === common.ACTIONS.ANNOUNCE ? 'announce' : 'scrape', + 'failure reason': err.message, + info_hash: hex2bin(params.info_hash) + }), socket.onSend) + + this.emit('warning', err) + return + } -Server.prototype._onAnnounce = function (params, cb) { - var self = this + response.action = params.action === common.ACTIONS.ANNOUNCE ? 'announce' : 'scrape' - var swarm = self.getSwarm(params.info_hash) - if (swarm) announce() - else createSwarm() + let peers + if (response.action === 'announce') { + peers = response.peers + delete response.peers - function createSwarm () { - if (self._filter) { - self._filter(params.info_hash, params, function (allowed) { - if (allowed instanceof Error) { - cb(allowed) - } else if (!allowed) { - cb(new Error('disallowed info_hash')) - } else { - swarm = self.createSwarm(params.info_hash) - announce() + if (!socket.infoHashes.includes(params.info_hash)) { + socket.infoHashes.push(params.info_hash) } - }) - } else { - swarm = self.createSwarm(params.info_hash) - announce() - } - } - function announce () { - if (!params.event || params.event === 'empty') params.event = 'update' - swarm.announce(params, function (err, response) { - if (err) return cb(err) + response.info_hash = hex2bin(params.info_hash) - if (!response.action) response.action = common.ACTIONS.ANNOUNCE - if (!response.interval) response.interval = Math.ceil(self._intervalMs / 1000) - - if (params.compact === 1) { - var peers = response.peers - - // Find IPv4 peers - response.peers = string2compact(peers.filter(function (peer) { - return common.IPV4_RE.test(peer.ip) - }).map(function (peer) { - return peer.ip + ':' + peer.port - })) - // Find IPv6 peers - response.peers6 = string2compact(peers.filter(function (peer) { - return common.IPV6_RE.test(peer.ip) - }).map(function (peer) { - return '[' + peer.ip + ']:' + peer.port - })) - } else if (params.compact === 0) { - // IPv6 peers are not separate for non-compact responses - response.peers = response.peers.map(function (peer) { - return { - 'peer id': peer.peerId, - ip: peer.ip, - port: peer.port - } + // WebSocket tracker should have a shorter interval – default: 2 minutes + response.interval = Math.ceil(this.intervalMs / 1000 / 5) + } + + // Skip sending update back for 'answer' announce messages – not needed + if (!params.answer) { + socket.send(JSON.stringify(response), socket.onSend) + debug('sent response %s to %s', JSON.stringify(response), params.peer_id) + } + + if (Array.isArray(params.offers)) { + debug('got %s offers from %s', params.offers.length, params.peer_id) + debug('got %s peers from swarm %s', peers.length, params.info_hash) + peers.forEach((peer, i) => { + peer.socket.send(JSON.stringify({ + action: 'announce', + offer: params.offers[i].offer, + offer_id: params.offers[i].offer_id, + peer_id: hex2bin(params.peer_id), + info_hash: hex2bin(params.info_hash) + }), peer.socket.onSend) + debug('sent offer to %s from %s', peer.peerId, params.peer_id) }) - } // else, return full peer objects (used for websocket responses) + } - cb(null, response) + const done = () => { + // emit event once the announce is fully "processed" + if (params.action === common.ACTIONS.ANNOUNCE) { + this.emit(common.EVENT_NAMES[params.event], params.peer_id, params) + } + } + + if (params.answer) { + debug('got answer %s from %s', JSON.stringify(params.answer), params.peer_id) + + this.getSwarm(params.info_hash, (err, swarm) => { + if (this.destroyed) return + if (err) return this.emit('warning', err) + if (!swarm) { + return this.emit('warning', new Error('no swarm with that `info_hash`')) + } + // Mark the destination peer as recently used in cache + const toPeer = swarm.peers.get(params.to_peer_id) + if (!toPeer) { + return this.emit('warning', new Error('no peer with that `to_peer_id`')) + } + + toPeer.socket.send(JSON.stringify({ + action: 'announce', + answer: params.answer, + offer_id: params.offer_id, + peer_id: hex2bin(params.peer_id), + info_hash: hex2bin(params.info_hash) + }), toPeer.socket.onSend) + debug('sent answer to %s from %s', toPeer.peerId, params.peer_id) + + done() + }) + } else { + done() + } }) } -} + _onWebSocketSend (socket, err) { + if (err) this._onWebSocketError(socket, err) + } -Server.prototype._onScrape = function (params, cb) { - var self = this + _onWebSocketClose (socket) { + debug('websocket close %s', socket.peerId) + socket.destroyed = true + + if (socket.peerId) { + socket.infoHashes.slice(0).forEach(infoHash => { + const swarm = this.torrents[infoHash] + if (swarm) { + swarm.announce({ + type: 'ws', + event: 'stopped', + numwant: 0, + peer_id: socket.peerId + }) + } + }) + } + + // ignore all future errors + socket.onSend = noop + socket.on('error', noop) + + socket.peerId = null + socket.infoHashes = null + + if (typeof socket.onMessageBound === 'function') { + socket.removeListener('message', socket.onMessageBound) + } + socket.onMessageBound = null + + if (typeof socket.onErrorBound === 'function') { + socket.removeListener('error', socket.onErrorBound) + } + socket.onErrorBound = null + + if (typeof socket.onCloseBound === 'function') { + socket.removeListener('close', socket.onCloseBound) + } + socket.onCloseBound = null + } + + _onWebSocketError (socket, err) { + debug('websocket error %s', err.message || err) + this.emit('warning', err) + this._onWebSocketClose(socket) + } - if (params.info_hash == null) { - // if info_hash param is omitted, stats for all torrents are returned - // TODO: make this configurable! - params.info_hash = Object.keys(self.torrents) + _onRequest (params, cb) { + if (params && params.action === common.ACTIONS.CONNECT) { + cb(null, { action: common.ACTIONS.CONNECT }) + } else if (params && params.action === common.ACTIONS.ANNOUNCE) { + this._onAnnounce(params, cb) + } else if (params && params.action === common.ACTIONS.SCRAPE) { + this._onScrape(params, cb) + } else { + cb(new Error('Invalid action')) + } } - series(params.info_hash.map(function (infoHash) { - return function (cb) { - var swarm = self.getSwarm(infoHash) - if (swarm) { - swarm.scrape(params, function (err, scrapeInfo) { + _onAnnounce (params, cb) { + const self = this + + if (this._filter) { + this._filter(params.info_hash, params, err => { + // Presence of `err` means that this announce request is disallowed + if (err) return cb(err) + + getOrCreateSwarm((err, swarm) => { if (err) return cb(err) - cb(null, { - infoHash: infoHash, - complete: (scrapeInfo && scrapeInfo.complete) || 0, - incomplete: (scrapeInfo && scrapeInfo.incomplete) || 0 - }) + announce(swarm) }) - } else { - cb(null, { infoHash: infoHash, complete: 0, incomplete: 0 }) - } + }) + } else { + getOrCreateSwarm((err, swarm) => { + if (err) return cb(err) + announce(swarm) + }) + } + + // Get existing swarm, or create one if one does not exist + function getOrCreateSwarm (cb) { + self.getSwarm(params.info_hash, (err, swarm) => { + if (err) return cb(err) + if (swarm) return cb(null, swarm) + self.createSwarm(params.info_hash, (err, swarm) => { + if (err) return cb(err) + cb(null, swarm) + }) + }) + } + + function announce (swarm) { + if (!params.event || params.event === 'empty') params.event = 'update' + swarm.announce(params, (err, response) => { + if (err) return cb(err) + + if (!response.action) response.action = common.ACTIONS.ANNOUNCE + if (!response.interval) response.interval = Math.ceil(self.intervalMs / 1000) + + if (params.compact === 1) { + const peers = response.peers + + // Find IPv4 peers + response.peers = string2compact(peers.filter(peer => common.IPV4_RE.test(peer.ip)).map(peer => `${peer.ip}:${peer.port}`)) + // Find IPv6 peers + response.peers6 = string2compact(peers.filter(peer => common.IPV6_RE.test(peer.ip)).map(peer => `[${peer.ip}]:${peer.port}`)) + } else if (params.compact === 0) { + // IPv6 peers are not separate for non-compact responses + response.peers = response.peers.map(peer => ({ + 'peer id': hex2bin(peer.peerId), + ip: peer.ip, + port: peer.port + })) + } // else, return full peer objects (used for websocket responses) + + cb(null, response) + }) } - }), function (err, results) { - if (err) return cb(err) + } - var response = { - action: common.ACTIONS.SCRAPE, - files: {}, - flags: { min_request_interval: Math.ceil(self._intervalMs / 1000) } + _onScrape (params, cb) { + if (params.info_hash == null) { + // if info_hash param is omitted, stats for all torrents are returned + // TODO: make this configurable! + params.info_hash = Object.keys(this.torrents) } - results.forEach(function (result) { - response.files[common.hexToBinary(result.infoHash)] = { - complete: result.complete || 0, - incomplete: result.incomplete || 0, - downloaded: result.complete || 0 // TODO: this only provides a lower-bound + series(params.info_hash.map(infoHash => cb => { + this.getSwarm(infoHash, (err, swarm) => { + if (err) return cb(err) + if (swarm) { + swarm.scrape(params, (err, scrapeInfo) => { + if (err) return cb(err) + cb(null, { + infoHash, + complete: (scrapeInfo && scrapeInfo.complete) || 0, + incomplete: (scrapeInfo && scrapeInfo.incomplete) || 0 + }) + }) + } else { + cb(null, { infoHash, complete: 0, incomplete: 0 }) + } + }) + }), (err, results) => { + if (err) return cb(err) + + const response = { + action: common.ACTIONS.SCRAPE, + files: {}, + flags: { min_request_interval: Math.ceil(this.intervalMs / 1000) } } - }) - cb(null, response) - }) + results.forEach(result => { + response.files[hex2bin(result.infoHash)] = { + complete: result.complete || 0, + incomplete: result.incomplete || 0, + downloaded: result.complete || 0 // TODO: this only provides a lower-bound + } + }) + + cb(null, response) + }) + } } +Server.Swarm = Swarm + function makeUdpPacket (params) { - var packet + let packet switch (params.action) { - case common.ACTIONS.CONNECT: + case common.ACTIONS.CONNECT: { packet = Buffer.concat([ common.toUInt32(common.ACTIONS.CONNECT), common.toUInt32(params.transactionId), params.connectionId ]) break - case common.ACTIONS.ANNOUNCE: + } + case common.ACTIONS.ANNOUNCE: { packet = Buffer.concat([ common.toUInt32(common.ACTIONS.ANNOUNCE), common.toUInt32(params.transactionId), @@ -507,13 +765,14 @@ function makeUdpPacket (params) { params.peers ]) break - case common.ACTIONS.SCRAPE: - var scrapeResponse = [ + } + case common.ACTIONS.SCRAPE: { + const scrapeResponse = [ common.toUInt32(common.ACTIONS.SCRAPE), common.toUInt32(params.transactionId) ] - for (var infoHash in params.files) { - var file = params.files[infoHash] + for (const infoHash in params.files) { + const file = params.files[infoHash] scrapeResponse.push( common.toUInt32(file.complete), common.toUInt32(file.downloaded), // TODO: this only provides a lower-bound @@ -522,49 +781,30 @@ function makeUdpPacket (params) { } packet = Buffer.concat(scrapeResponse) break - case common.ACTIONS.ERROR: + } + case common.ACTIONS.ERROR: { packet = Buffer.concat([ common.toUInt32(common.ACTIONS.ERROR), common.toUInt32(params.transactionId || 0), - new Buffer(params['failure reason'], 'utf8') + Buffer.from(String(params['failure reason'])) ]) break + } default: - throw new Error('Action not implemented: ' + params.action) + throw new Error(`Action not implemented: ${params.action}`) } return packet } -Server.prototype._onWebSocketSend = function (socket, err) { - var self = this - if (err) self._onWebSocketError(socket, err) -} - -Server.prototype._onWebSocketClose = function (socket) { - var self = this - if (!socket.peerId || !socket.infoHashes) return - debug('websocket close') - - socket.infoHashes.forEach(function (infoHash) { - var swarm = self.torrents[infoHash] - if (swarm) { - swarm.announce({ - event: 'stopped', - numwant: 0, - peer_id: socket.peerId - }, function () {}) - } - }) -} - -Server.prototype._onWebSocketError = function (socket, err) { - var self = this - debug('websocket error %s', err.message || err) - self.emit('warning', err) - self._onWebSocketClose(socket) +function isObject (obj) { + return typeof obj === 'object' && obj !== null } function toNumber (x) { x = Number(x) return x >= 0 ? x : false } + +function noop () {} + +export default Server diff --git a/test/client-large-torrent.js b/test/client-large-torrent.js index 7b33ad853..db0d0fbf9 100644 --- a/test/client-large-torrent.js +++ b/test/client-large-torrent.js @@ -1,56 +1,63 @@ -var Client = require('../') -var fs = require('fs') -var parseTorrent = require('parse-torrent') -var Server = require('../').Server -var test = require('tape') - -var torrent = fs.readFileSync(__dirname + '/torrents/sintel-5gb.torrent') -var parsedTorrent = parseTorrent(torrent) -var peerId = new Buffer('01234567890123456789') - -test('large torrent: client.start()', function (t) { - t.plan(5) - - var server = new Server({ http: false }) +import Client from '../index.js' +import common from './common.js' +import fixtures from 'webtorrent-fixtures' +import test from 'tape' + +const peerId = Buffer.from('01234567890123456789') + +function testLargeTorrent (t, serverType) { + t.plan(9) + + common.createServer(t, serverType, (server, announceUrl) => { + const client = new Client({ + infoHash: fixtures.sintel.parsedTorrent.infoHash, + peerId, + port: 6881, + announce: announceUrl, + wrtc: {} + }) - server.on('error', function (err) { - t.fail(err.message) - }) + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) - server.on('warning', function (err) { - t.fail(err.message) - }) + client.once('update', data => { + t.equal(data.announce, announceUrl) + t.equal(typeof data.complete, 'number') + t.equal(typeof data.incomplete, 'number') - server.listen(0, function () { - var port = server.udp.address().port + client.update() - // remove all tracker servers except a single UDP one, for now - parsedTorrent.announce = [ 'udp://127.0.0.1:' + port ] + client.once('update', data => { + t.equal(data.announce, announceUrl) + t.equal(typeof data.complete, 'number') + t.equal(typeof data.incomplete, 'number') - var client = new Client(peerId, 6881, parsedTorrent) + client.stop() - client.on('error', function (err) { - t.error(err) - }) + client.once('update', data => { + t.equal(data.announce, announceUrl) + t.equal(typeof data.complete, 'number') + t.equal(typeof data.incomplete, 'number') - client.once('update', function (data) { - t.equal(data.announce, 'udp://127.0.0.1:' + port) - t.equal(typeof data.complete, 'number') - t.equal(typeof data.incomplete, 'number') + server.close() + client.destroy() + }) + }) }) client.start() + }) +} - client.once('peer', function () { - t.pass('there is at least one peer') +test('http: large torrent: client.start()', t => { + testLargeTorrent(t, 'http') +}) - client.stop() +test('udp: large torrent: client.start()', t => { + testLargeTorrent(t, 'udp') +}) - client.once('update', function () { - t.pass('got response to stop') - server.close() - client.destroy() - }) - }) - }) +test('ws: large torrent: client.start()', t => { + testLargeTorrent(t, 'ws') }) diff --git a/test/client-magnet.js b/test/client-magnet.js index 47e972a64..a6268b3fc 100644 --- a/test/client-magnet.js +++ b/test/client-magnet.js @@ -1,61 +1,44 @@ -var Client = require('../') -var magnet = require('magnet-uri') -var Server = require('../').Server -var test = require('tape') - -var uri = 'magnet:?xt=urn:btih:d2474e86c95b19b8bcfdb92bc12c9d44667cfa36&dn=Leaves+of+Grass+by+Walt+Whitman.epub&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A80' -var parsedTorrent = magnet(uri) -var peerId = new Buffer('01234567890123456789') - -test('magnet + udp: client.start/update/stop()', function (t) { - t.plan(10) - - var server = new Server({ http: false }) - - server.on('error', function (err) { - t.fail(err.message) - }) - - server.on('warning', function (err) { - t.fail(err.message) - }) - - server.listen(0, function () { - var port = server.udp.address().port - var announceUrl = 'udp://127.0.0.1:' + port - - // remove all tracker servers except a single UDP one, for now - parsedTorrent.announce = [ announceUrl ] - - var client = new Client(peerId, 6881, parsedTorrent) - - client.on('error', function (err) { - t.error(err) +import Client from '../index.js' +import common from './common.js' +import fixtures from 'webtorrent-fixtures' +import magnet from 'magnet-uri' +import test from 'tape' + +const peerId = Buffer.from('01234567890123456789') + +function testMagnet (t, serverType) { + t.plan(9) + + const parsedTorrent = magnet(fixtures.leaves.magnetURI) + + common.createServer(t, serverType, (server, announceUrl) => { + const client = new Client({ + infoHash: parsedTorrent.infoHash, + announce: announceUrl, + peerId, + port: 6881, + wrtc: {} }) - client.once('update', function (data) { + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) + + client.once('update', data => { t.equal(data.announce, announceUrl) t.equal(typeof data.complete, 'number') t.equal(typeof data.incomplete, 'number') - }) - - client.start() - - client.once('peer', function () { - t.pass('there is at least one peer') client.update() - client.once('update', function (data) { - // receive one final update after calling stop + client.once('update', data => { t.equal(data.announce, announceUrl) t.equal(typeof data.complete, 'number') t.equal(typeof data.incomplete, 'number') client.stop() - client.once('update', function (data) { - // received an update! + client.once('update', data => { t.equal(data.announce, announceUrl) t.equal(typeof data.complete, 'number') t.equal(typeof data.incomplete, 'number') @@ -65,5 +48,19 @@ test('magnet + udp: client.start/update/stop()', function (t) { }) }) }) + + client.start() }) +} + +test('http: magnet: client.start/update/stop()', t => { + testMagnet(t, 'http') +}) + +test('udp: magnet: client.start/update/stop()', t => { + testMagnet(t, 'udp') +}) + +test('ws: magnet: client.start/update/stop()', t => { + testMagnet(t, 'ws') }) diff --git a/test/client-ws-socket-pool.js b/test/client-ws-socket-pool.js new file mode 100644 index 000000000..6d3fe1a3f --- /dev/null +++ b/test/client-ws-socket-pool.js @@ -0,0 +1,56 @@ +import Client from '../index.js' +import common from './common.js' +import fixtures from 'webtorrent-fixtures' +import test from 'tape' + +const peerId = Buffer.from('01234567890123456789') +const port = 6681 + +test('ensure client.destroy() callback is called with re-used websockets in socketPool', t => { + t.plan(4) + + common.createServer(t, 'ws', (server, announceUrl) => { + const client1 = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId, + port, + wrtc: {} + }) + + common.mockWebsocketTracker(client1) + client1.on('error', err => { t.error(err) }) + client1.on('warning', err => { t.error(err) }) + + client1.start() + + client1.once('update', () => { + t.pass('got client1 update') + // second ws client using same announce url will re-use the same websocket + const client2 = new Client({ + infoHash: fixtures.alice.parsedTorrent.infoHash, // different info hash + announce: announceUrl, + peerId, + port, + wrtc: {} + }) + + common.mockWebsocketTracker(client2) + client2.on('error', err => { t.error(err) }) + client2.on('warning', err => { t.error(err) }) + + client2.start() + + client2.once('update', () => { + t.pass('got client2 update') + client1.destroy(err => { + t.error(err, 'got client1 destroy callback') + client2.destroy(err => { + t.error(err, 'got client2 destroy callback') + server.close() + }) + }) + }) + }) + }) +}) diff --git a/test/client.js b/test/client.js index d1bd88718..d81ee55f0 100644 --- a/test/client.js +++ b/test/client.js @@ -1,41 +1,40 @@ -var Client = require('../') -var common = require('./common') -var fs = require('fs') -var parseTorrent = require('parse-torrent') -var test = require('tape') - -var torrent = fs.readFileSync(__dirname + '/torrents/bitlove-intro.torrent') -var parsedTorrent = parseTorrent(torrent) -var peerId1 = new Buffer('01234567890123456789') -var peerId2 = new Buffer('12345678901234567890') -var peerId3 = new Buffer('23456789012345678901') -var port = 6881 +import Client from '../index.js' +import common from './common.js' +import http from 'http' +import fixtures from 'webtorrent-fixtures' +import net from 'net' +import test from 'tape' +import undici from 'undici' + +const peerId1 = Buffer.from('01234567890123456789') +const peerId2 = Buffer.from('12345678901234567890') +const peerId3 = Buffer.from('23456789012345678901') +const port = 6881 function testClientStart (t, serverType) { - t.plan(5) - common.createServer(t, serverType, function (server, announceUrl) { - parsedTorrent.announce = [ announceUrl ] - var client = new Client(peerId1, port, parsedTorrent) + t.plan(4) - client.on('error', function (err) { - t.error(err) + common.createServer(t, serverType, (server, announceUrl) => { + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId1, + port, + wrtc: {} }) - client.on('warning', function (err) { - t.error(err) - }) + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) - client.once('update', function (data) { + client.once('update', data => { t.equal(data.announce, announceUrl) t.equal(typeof data.complete, 'number') t.equal(typeof data.incomplete, 'number') - }) - client.once('peer', function () { - t.pass('there is at least one peer') client.stop() - client.once('update', function () { + client.once('update', () => { t.pass('got response to stop') server.close() client.destroy() @@ -46,34 +45,42 @@ function testClientStart (t, serverType) { }) } -test('http: client.start()', function (t) { +test('http: client.start()', t => { testClientStart(t, 'http') }) -test('udp: client.start()', function (t) { +test('udp: client.start()', t => { testClientStart(t, 'udp') }) +test('ws: client.start()', t => { + testClientStart(t, 'ws') +}) + function testClientStop (t, serverType) { - t.plan(3) - common.createServer(t, serverType, function (server, announceUrl) { - parsedTorrent.announce = [ announceUrl ] - var client = new Client(peerId1, port, parsedTorrent) + t.plan(4) - client.on('error', function (err) { - t.error(err) + common.createServer(t, serverType, (server, announceUrl) => { + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId1, + port, + wrtc: {} }) - client.on('warning', function (err) { - t.error(err) - }) + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) client.start() - setTimeout(function () { + client.once('update', () => { + t.pass('client received response to "start" message') + client.stop() - client.once('update', function (data) { + client.once('update', data => { // receive one final update after calling stop t.equal(data.announce, announceUrl) t.equal(typeof data.complete, 'number') @@ -82,44 +89,108 @@ function testClientStop (t, serverType) { server.close() client.destroy() }) - }, 1000) + }) }) } -test('http: client.stop()', function (t) { +test('http: client.stop()', t => { testClientStop(t, 'http') }) -test('udp: client.stop()', function (t) { +test('udp: client.stop()', t => { testClientStop(t, 'udp') }) +test('ws: client.stop()', t => { + testClientStop(t, 'ws') +}) + +function testClientStopDestroy (t, serverType) { + t.plan(2) + + common.createServer(t, serverType, (server, announceUrl) => { + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId1, + port, + wrtc: {} + }) + + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) + + client.start() + + client.once('update', () => { + t.pass('client received response to "start" message') + + client.stop() + + client.on('update', () => { t.fail('client should not receive update after destroy is called') }) + + // Call destroy() in the same tick as stop(), but the message should still + // be received by the server, though obviously the client won't receive the + // response. + client.destroy() + + server.once('stop', (peer, params) => { + t.pass('server received "stop" message') + setTimeout(() => { + // give the websocket server time to finish in progress (stream) messages + // to peers + server.close() + }, 100) + }) + }) + }) +} + +test('http: client.stop(); client.destroy()', t => { + testClientStopDestroy(t, 'http') +}) + +test('udp: client.stop(); client.destroy()', t => { + testClientStopDestroy(t, 'udp') +}) + +test('ws: client.stop(); client.destroy()', t => { + testClientStopDestroy(t, 'ws') +}) + function testClientUpdate (t, serverType) { t.plan(4) - common.createServer(t, serverType, function (server, announceUrl) { - parsedTorrent.announce = [ announceUrl ] - var client = new Client(peerId1, port, parsedTorrent, { interval: 2000 }) - client.on('error', function (err) { - t.error(err) + common.createServer(t, serverType, (server, announceUrl) => { + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId1, + port, + wrtc: {} }) - client.on('warning', function (err) { - t.error(err) - }) + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) + + client.setInterval(500) client.start() - client.once('update', function () { - // after interval (2s), we should get another update - client.once('update', function (data) { + client.once('update', () => { + client.setInterval(500) + + // after interval, we should get another update + client.once('update', data => { // received an update! t.equal(data.announce, announceUrl) t.equal(typeof data.complete, 'number') t.equal(typeof data.incomplete, 'number') client.stop() - client.once('update', function () { + client.once('update', () => { t.pass('got response to stop') server.close() client.destroy() @@ -129,29 +200,35 @@ function testClientUpdate (t, serverType) { }) } -test('http: client.update()', function (t) { +test('http: client.update()', t => { testClientUpdate(t, 'http') }) -test('udp: client.update()', function (t) { +test('udp: client.update()', t => { testClientUpdate(t, 'udp') }) +test('ws: client.update()', t => { + testClientUpdate(t, 'ws') +}) + function testClientScrape (t, serverType) { t.plan(4) - common.createServer(t, serverType, function (server, announceUrl) { - parsedTorrent.announce = [ announceUrl ] - var client = new Client(peerId1, port, parsedTorrent) - client.on('error', function (err) { - t.error(err) + common.createServer(t, serverType, (server, announceUrl) => { + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId1, + port, + wrtc: {} }) - client.on('warning', function (err) { - t.error(err) - }) + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) - client.once('scrape', function (data) { + client.once('scrape', data => { t.equal(data.announce, announceUrl) t.equal(typeof data.complete, 'number') t.equal(typeof data.incomplete, 'number') @@ -165,59 +242,185 @@ function testClientScrape (t, serverType) { }) } -test('http: client.scrape()', function (t) { +test('http: client.scrape()', t => { testClientScrape(t, 'http') }) -test('udp: client.scrape()', function (t) { +test('udp: client.scrape()', t => { testClientScrape(t, 'udp') }) +test('ws: client.scrape()', t => { + testClientScrape(t, 'ws') +}) + +function testClientAnnounceWithParams (t, serverType) { + t.plan(5) + + common.createServer(t, serverType, (server, announceUrl) => { + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId1, + port, + wrtc: {} + }) + + server.on('start', (peer, params) => { + t.equal(params.testParam, 'this is a test') + }) + + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) + + client.once('update', data => { + t.equal(data.announce, announceUrl) + t.equal(typeof data.complete, 'number') + t.equal(typeof data.incomplete, 'number') + + client.stop() + + client.once('update', () => { + t.pass('got response to stop') + server.close() + client.destroy() + }) + }) + + client.start({ + testParam: 'this is a test' + }) + }) +} + +test('http: client.announce() with params', t => { + testClientAnnounceWithParams(t, 'http') +}) + +test('ws: client.announce() with params', t => { + testClientAnnounceWithParams(t, 'ws') +}) + +function testClientGetAnnounceOpts (t, serverType) { + t.plan(5) + + common.createServer(t, serverType, (server, announceUrl) => { + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId1, + port, + getAnnounceOpts () { + return { + testParam: 'this is a test' + } + }, + wrtc: {} + }) + + server.on('start', (peer, params) => { + t.equal(params.testParam, 'this is a test') + }) + + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) + + client.once('update', data => { + t.equal(data.announce, announceUrl) + t.equal(typeof data.complete, 'number') + t.equal(typeof data.incomplete, 'number') + + client.stop() + + client.once('update', () => { + t.pass('got response to stop') + server.close() + client.destroy() + }) + }) + + client.start() + }) +} + +test('http: client `opts.getAnnounceOpts`', t => { + testClientGetAnnounceOpts(t, 'http') +}) + +test('ws: client `opts.getAnnounceOpts`', t => { + testClientGetAnnounceOpts(t, 'ws') +}) + function testClientAnnounceWithNumWant (t, serverType) { t.plan(4) - common.createServer(t, serverType, function (server, announceUrl) { - parsedTorrent.announce = [ announceUrl ] - var client1 = new Client(peerId1, port, parsedTorrent) - client1.on('error', function (err) { - t.error(err) + + common.createServer(t, serverType, (server, announceUrl) => { + const client1 = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: [announceUrl], + peerId: peerId1, + port, + wrtc: {} }) + if (serverType === 'ws') common.mockWebsocketTracker(client1) + client1.on('error', err => { t.error(err) }) + client1.on('warning', err => { t.error(err) }) + client1.start() - client1.once('update', function () { - var client2 = new Client(peerId2, port + 1, parsedTorrent) - client2.on('error', function (err) { - t.error(err) + client1.once('update', () => { + const client2 = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId2, + port: port + 1, + wrtc: {} }) + + if (serverType === 'ws') common.mockWebsocketTracker(client2) + client2.on('error', err => { t.error(err) }) + client2.on('warning', err => { t.error(err) }) + client2.start() - client2.once('update', function () { - var client3 = new Client(peerId3, port + 2, parsedTorrent, { numWant: 1 }) - client3.on('error', function (err) { - t.error(err) + client2.once('update', () => { + const client3 = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId3, + port: port + 2, + wrtc: {} }) - client3.start() - client3.on('peer', function () { + + if (serverType === 'ws') common.mockWebsocketTracker(client3) + client3.on('error', err => { t.error(err) }) + client3.on('warning', err => { t.error(err) }) + + client3.start({ numwant: 1 }) + client3.on('peer', () => { t.pass('got one peer (this should only fire once)') - var num = 3 + let num = 3 function tryCloseServer () { num -= 1 if (num === 0) server.close() } client1.stop() - client1.once('update', function () { + client1.once('update', () => { t.pass('got response to stop (client1)') client1.destroy() tryCloseServer() }) client2.stop() - client2.once('update', function () { + client2.once('update', () => { t.pass('got response to stop (client2)') client2.destroy() tryCloseServer() }) client3.stop() - client3.once('update', function () { + client3.once('update', () => { t.pass('got response to stop (client3)') client3.destroy() tryCloseServer() @@ -228,10 +431,210 @@ function testClientAnnounceWithNumWant (t, serverType) { }) } -test('http: client announce with numWant', function (t) { +test('http: client announce with numwant', t => { testClientAnnounceWithNumWant(t, 'http') }) -test('udp: client announce with numWant', function (t) { +test('udp: client announce with numwant', t => { testClientAnnounceWithNumWant(t, 'udp') }) + +test('http: userAgent', t => { + t.plan(2) + + common.createServer(t, 'http', (server, announceUrl) => { + // Confirm that user-agent header is set + server.http.on('request', (req, res) => { + t.ok(req.headers['user-agent'].includes('WebTorrent')) + }) + + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId1, + port, + userAgent: 'WebTorrent/0.98.0 (https://webtorrent.io)', + wrtc: {} + }) + + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) + + client.once('update', data => { + t.equal(data.announce, announceUrl) + + server.close() + client.destroy() + }) + + client.start() + }) +}) + +function testSupportedTracker (t, serverType) { + t.plan(1) + + common.createServer(t, serverType, (server, announceUrl) => { + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId1, + port, + wrtc: {} + }) + + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) + + client.start() + + client.once('update', data => { + t.pass('tracker is valid') + + server.close() + client.destroy() + }) + }) +} + +test('http: valid tracker port', t => { + testSupportedTracker(t, 'http') +}) + +test('udp: valid tracker port', t => { + testSupportedTracker(t, 'udp') +}) + +test('ws: valid tracker port', t => { + testSupportedTracker(t, 'ws') +}) + +function testUnsupportedTracker (t, announceUrl) { + t.plan(1) + + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId1, + port, + wrtc: {} + }) + + client.on('error', err => { t.error(err) }) + client.on('warning', err => { + t.ok(err.message.includes('tracker'), 'got warning') + + client.destroy() + }) +} + +test('unsupported tracker protocol', t => { + testUnsupportedTracker(t, 'badprotocol://127.0.0.1:8080/announce') +}) + +test('http: invalid tracker port', t => { + testUnsupportedTracker(t, 'http://127.0.0.1:69691337/announce') +}) + +test('http: invalid tracker url', t => { + testUnsupportedTracker(t, 'http:') +}) + +test('http: invalid tracker url with slash', t => { + testUnsupportedTracker(t, 'http://') +}) + +test('udp: invalid tracker port', t => { + testUnsupportedTracker(t, 'udp://127.0.0.1:69691337') +}) + +test('udp: invalid tracker url', t => { + testUnsupportedTracker(t, 'udp:') +}) + +test('udp: invalid tracker url with slash', t => { + testUnsupportedTracker(t, 'udp://') +}) + +test('ws: invalid tracker port', t => { + testUnsupportedTracker(t, 'ws://127.0.0.1:69691337') +}) + +test('ws: invalid tracker url', t => { + testUnsupportedTracker(t, 'ws:') +}) + +test('ws: invalid tracker url with slash', t => { + testUnsupportedTracker(t, 'ws://') +}) + +function testClientStartHttpAgent (t, serverType) { + t.plan(5) + + common.createServer(t, serverType, function (server, announceUrl) { + let agent + if (global.fetch && serverType !== 'ws') { + const connector = undici.buildConnector({ rejectUnauthorized: false }) + agent = new undici.Agent({ + connect (opts, cb) { + agentUsed = true + connector(opts, (err, socket) => { + if (err) { + cb(err, null) + } else { + cb(null, socket) + } + }) + } + }) + } else { + agent = new http.Agent() + agent.createConnection = function (opts, fn) { + agentUsed = true + return net.createConnection(opts, fn) + } + } + let agentUsed = false + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId1, + port, + wrtc: {}, + proxyOpts: { + httpAgent: agent + } + }) + + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', function (err) { t.error(err) }) + client.on('warning', function (err) { t.error(err) }) + + client.once('update', function (data) { + t.equal(data.announce, announceUrl) + t.equal(typeof data.complete, 'number') + t.equal(typeof data.incomplete, 'number') + + t.ok(agentUsed) + + client.stop() + + client.once('update', function () { + t.pass('got response to stop') + server.close() + client.destroy() + }) + }) + + client.start() + }) +} + +test('http: client.start(httpAgent)', function (t) { + testClientStartHttpAgent(t, 'http') +}) + +test('ws: client.start(httpAgent)', function (t) { + testClientStartHttpAgent(t, 'ws') +}) diff --git a/test/common.js b/test/common.js index 72de4d9d7..5ff6ce39f 100644 --- a/test/common.js +++ b/test/common.js @@ -1,23 +1,42 @@ -var Server = require('../').Server +import { Server } from '../index.js' -exports.createServer = function (t, serverType, cb) { - var opts = serverType === 'http' ? { udp: false } : { http: false } - var server = new Server(opts) +export const createServer = (t, opts, cb) => { + if (typeof opts === 'string') opts = { serverType: opts } - server.on('error', function (err) { - t.error(err) - }) + opts.http = (opts.serverType === 'http') + opts.udp = (opts.serverType === 'udp') + opts.ws = (opts.serverType === 'ws') - server.on('warning', function (err) { - t.error(err) - }) + const server = new Server(opts) + + server.on('error', err => { t.error(err) }) + server.on('warning', err => { t.error(err) }) - server.listen(0, function () { - var port = server[serverType].address().port - var announceUrl = serverType === 'http' - ? 'http://127.0.0.1:' + port + '/announce' - : 'udp://127.0.0.1:' + port + server.listen(0, () => { + const port = server[opts.serverType].address().port + let announceUrl + if (opts.serverType === 'http') { + announceUrl = `http://127.0.0.1:${port}/announce` + } else if (opts.serverType === 'udp') { + announceUrl = `udp://127.0.0.1:${port}` + } else if (opts.serverType === 'ws') { + announceUrl = `ws://127.0.0.1:${port}` + } cb(server, announceUrl) }) } + +export const mockWebsocketTracker = client => { + client._trackers[0]._generateOffers = (numwant, cb) => { + const offers = [] + for (let i = 0; i < numwant; i++) { + offers.push({ fake_offer: `fake_offer_${i}` }) + } + process.nextTick(() => { + cb(offers) + }) + } +} + +export default { mockWebsocketTracker, createServer } diff --git a/test/destroy.js b/test/destroy.js new file mode 100644 index 000000000..e9d7af3a4 --- /dev/null +++ b/test/destroy.js @@ -0,0 +1,50 @@ +import Client from '../index.js' +import common from './common.js' +import fixtures from 'webtorrent-fixtures' +import test from 'tape' + +const peerId = Buffer.from('01234567890123456789') +const port = 6881 + +function testNoEventsAfterDestroy (t, serverType) { + t.plan(1) + + common.createServer(t, serverType, (server, announceUrl) => { + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId, + port, + wrtc: {} + }) + + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) + + client.once('update', () => { + t.fail('no "update" event should fire, since client is destroyed') + }) + + // announce, then immediately destroy + client.update() + client.destroy() + + setTimeout(() => { + t.pass('wait to see if any events are fired') + server.close() + }, 1000) + }) +} + +test('http: no "update" events after destroy()', t => { + testNoEventsAfterDestroy(t, 'http') +}) + +test('udp: no "update" events after destroy()', t => { + testNoEventsAfterDestroy(t, 'udp') +}) + +test('ws: no "update" events after destroy()', t => { + testNoEventsAfterDestroy(t, 'ws') +}) diff --git a/test/evict.js b/test/evict.js new file mode 100644 index 000000000..385fae2a6 --- /dev/null +++ b/test/evict.js @@ -0,0 +1,117 @@ +import Client from '../index.js' +import common from './common.js' +import test from 'tape' +import wrtc from 'webrtc-polyfill' + +const infoHash = '4cb67059ed6bd08362da625b3ae77f6f4a075705' +const peerId = Buffer.from('01234567890123456789') +const peerId2 = Buffer.from('12345678901234567890') +const peerId3 = Buffer.from('23456789012345678901') + +function serverTest (t, serverType, serverFamily) { + t.plan(10) + + const hostname = serverFamily === 'inet6' + ? '[::1]' + : '127.0.0.1' + + const opts = { + serverType, + peersCacheLength: 2 // LRU cache can only contain a max of 2 peers + } + + common.createServer(t, opts, server => { + // Not using announceUrl param from `common.createServer()` since we + // want to control IPv4 vs IPv6. + const port = server[serverType].address().port + const announceUrl = `${serverType}://${hostname}:${port}/announce` + + const client1 = new Client({ + infoHash, + announce: [announceUrl], + peerId, + port: 6881, + wrtc + }) + if (serverType === 'ws') common.mockWebsocketTracker(client1) + + client1.start() + + client1.once('update', data => { + const client2 = new Client({ + infoHash, + announce: [announceUrl], + peerId: peerId2, + port: 6882, + wrtc + }) + if (serverType === 'ws') common.mockWebsocketTracker(client2) + + client2.start() + + client2.once('update', data => { + server.getSwarm(infoHash, (err, swarm) => { + t.error(err) + + t.equal(swarm.complete + swarm.incomplete, 2) + + // Ensure that first peer is evicted when a third one is added + let evicted = false + swarm.peers.once('evict', evictedPeer => { + t.equal(evictedPeer.value.peerId, peerId.toString('hex')) + t.equal(swarm.complete + swarm.incomplete, 2) + evicted = true + }) + + const client3 = new Client({ + infoHash, + announce: [announceUrl], + peerId: peerId3, + port: 6880, + wrtc + }) + if (serverType === 'ws') common.mockWebsocketTracker(client3) + + client3.start() + + client3.once('update', data => { + t.ok(evicted, 'client1 was evicted from server before client3 gets response') + t.equal(swarm.complete + swarm.incomplete, 2) + + client1.destroy(() => { + t.pass('client1 destroyed') + }) + + client2.destroy(() => { + t.pass('client3 destroyed') + }) + + client3.destroy(() => { + t.pass('client3 destroyed') + }) + + server.close(() => { + t.pass('server destroyed') + }) + }) + }) + }) + }) + }) +} + +test('evict: ipv4 server', t => { + serverTest(t, 'http', 'inet') +}) + +test('evict: http ipv6 server', t => { + serverTest(t, 'http', 'inet6') +}) + +test('evict: udp server', t => { + serverTest(t, 'udp', 'inet') +}) + +test('evict: ws server', t => { + serverTest(t, 'ws', 'inet') +}) diff --git a/test/filter.js b/test/filter.js index 7be9bb2d2..4dc3da02d 100644 --- a/test/filter.js +++ b/test/filter.js @@ -1,170 +1,165 @@ -var Client = require('../') -var fs = require('fs') -var parseTorrent = require('parse-torrent') -var Server = require('../').Server -var test = require('tape') +import Client from '../index.js' +import common from './common.js' +import fixtures from 'webtorrent-fixtures' +import test from 'tape' -var bitlove = fs.readFileSync(__dirname + '/torrents/bitlove-intro.torrent') -var parsedBitlove = parseTorrent(bitlove) - -var leaves = fs.readFileSync(__dirname + '/torrents/leaves.torrent') -var parsedLeaves = parseTorrent(leaves) - -var peerId = new Buffer('01234567890123456789') +const peerId = Buffer.from('01234567890123456789') function testFilterOption (t, serverType) { t.plan(8) - var opts = serverType === 'http' ? { udp: false } : { http: false } - opts.filter = function (infoHash, params, cb) { - process.nextTick(function () { - cb(infoHash !== parsedBitlove.infoHash) + + const opts = { serverType } // this is test-suite-only option + opts.filter = (infoHash, params, cb) => { + process.nextTick(() => { + if (infoHash === fixtures.alice.parsedTorrent.infoHash) { + cb(new Error('disallowed info_hash (Alice)')) + } else { + cb(null) + } }) } - var server = new Server(opts) - - server.on('error', function (err) { - t.error(err) - }) - - server.listen(0, function () { - var port = server[serverType].address().port - var announceUrl = serverType === 'http' - ? 'http://127.0.0.1:' + port + '/announce' - : 'udp://127.0.0.1:' + port - parsedBitlove.announce = [ announceUrl ] - parsedLeaves.announce = [ announceUrl ] - - var client = new Client(peerId, port, parsedBitlove) - - client.on('error', function (err) { - t.error(err) + common.createServer(t, opts, (server, announceUrl) => { + const client1 = new Client({ + infoHash: fixtures.alice.parsedTorrent.infoHash, + announce: announceUrl, + peerId, + port: 6881, + wrtc: {} }) - client.once('warning', function (err) { - t.ok(/disallowed info_hash/.test(err.message), 'got client warning') + client1.on('error', err => { t.error(err) }) + if (serverType === 'ws') common.mockWebsocketTracker(client1) - client.destroy(function () { - t.pass('client destroyed') - client = new Client(peerId, port, parsedLeaves) + client1.once('warning', err => { + t.ok(err.message.includes('disallowed info_hash (Alice)'), 'got client warning') - client.on('error', function (err) { - t.error(err) - }) - client.on('warning', function (err) { - t.error(err) + client1.destroy(() => { + t.pass('client1 destroyed') + + const client2 = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId, + port: 6881, + wrtc: {} }) + if (serverType === 'ws') common.mockWebsocketTracker(client2) + + client2.on('error', err => { t.error(err) }) + client2.on('warning', err => { t.error(err) }) - client.on('update', function () { + client2.on('update', () => { t.pass('got announce') - client.destroy(function () { - t.pass('client destroyed') - }) - server.close(function () { - t.pass('server closed') - }) + client2.destroy(() => { t.pass('client2 destroyed') }) + server.close(() => { t.pass('server closed') }) }) - server.on('start', function () { + server.on('start', () => { t.equal(Object.keys(server.torrents).length, 1) }) - client.start() + client2.start() }) }) - server.once('warning', function (err) { - t.ok(/disallowed info_hash/.test(err.message), 'got server warning') + server.removeAllListeners('warning') + server.once('warning', err => { + t.ok(err.message.includes('disallowed info_hash (Alice)'), 'got server warning') t.equal(Object.keys(server.torrents).length, 0) }) - client.start() + client1.start() }) } -test('http: filter option blocks tracker from tracking torrent', function (t) { +test('http: filter option blocks tracker from tracking torrent', t => { testFilterOption(t, 'http') }) -test('udp: filter option blocks tracker from tracking torrent', function (t) { +test('udp: filter option blocks tracker from tracking torrent', t => { testFilterOption(t, 'udp') }) +test('ws: filter option blocks tracker from tracking torrent', t => { + testFilterOption(t, 'ws') +}) + function testFilterCustomError (t, serverType) { t.plan(8) - var opts = serverType === 'http' ? { udp: false } : { http: false } - opts.filter = function (infoHash, params, cb) { - process.nextTick(function () { - if (infoHash === parsedBitlove.infoHash) cb(new Error('bitlove blocked')) - else cb(true) + + const opts = { serverType } // this is test-suite-only option + opts.filter = (infoHash, params, cb) => { + process.nextTick(() => { + if (infoHash === fixtures.alice.parsedTorrent.infoHash) { + cb(new Error('alice blocked')) + } else { + cb(null) + } }) } - var server = new Server(opts) - - server.on('error', function (err) { - t.error(err) - }) - - server.listen(0, function () { - var port = server[serverType].address().port - var announceUrl = serverType === 'http' - ? 'http://127.0.0.1:' + port + '/announce' - : 'udp://127.0.0.1:' + port - - parsedBitlove.announce = [ announceUrl ] - parsedLeaves.announce = [ announceUrl ] - var client = new Client(peerId, port, parsedBitlove) - - client.on('error', function (err) { - t.error(err) + common.createServer(t, opts, (server, announceUrl) => { + const client1 = new Client({ + infoHash: fixtures.alice.parsedTorrent.infoHash, + announce: announceUrl, + peerId, + port: 6881, + wrtc: {} }) - client.once('warning', function (err) { - t.ok(/bitlove blocked/.test(err.message), 'got client warning') + client1.on('error', err => { t.error(err) }) + if (serverType === 'ws') common.mockWebsocketTracker(client1) - client.destroy(function () { - t.pass('client destroyed') - client = new Client(peerId, port, parsedLeaves) + client1.once('warning', err => { + t.ok(/alice blocked/.test(err.message), 'got client warning') - client.on('error', function (err) { - t.error(err) - }) - client.on('warning', function (err) { - t.error(err) + client1.destroy(() => { + t.pass('client1 destroyed') + const client2 = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId, + port: 6881, + wrtc: {} }) + if (serverType === 'ws') common.mockWebsocketTracker(client2) + + client2.on('error', err => { t.error(err) }) + client2.on('warning', err => { t.error(err) }) - client.on('update', function () { + client2.on('update', () => { t.pass('got announce') - client.destroy(function () { - t.pass('client destroyed') - }) - server.close(function () { - t.pass('server closed') - }) + client2.destroy(() => { t.pass('client2 destroyed') }) + server.close(() => { t.pass('server closed') }) }) - server.on('start', function () { + server.on('start', () => { t.equal(Object.keys(server.torrents).length, 1) }) - client.start() + client2.start() }) }) - server.once('warning', function (err) { - t.ok(/bitlove blocked/.test(err.message), 'got server warning') + server.removeAllListeners('warning') + server.once('warning', err => { + t.ok(/alice blocked/.test(err.message), 'got server warning') t.equal(Object.keys(server.torrents).length, 0) }) - client.start() + client1.start() }) } -test('http: filter option with custom error', function (t) { +test('http: filter option with custom error', t => { testFilterCustomError(t, 'http') }) -test('udp: filter option filter option with custom error', function (t) { +test('udp: filter option filter option with custom error', t => { testFilterCustomError(t, 'udp') }) + +test('ws: filter option filter option with custom error', t => { + testFilterCustomError(t, 'ws') +}) diff --git a/test/querystring.js b/test/querystring.js index cf9d33d69..85e14e544 100644 --- a/test/querystring.js +++ b/test/querystring.js @@ -1,12 +1,12 @@ -var common = require('../lib/common') -var test = require('tape') +import common from '../lib/common.js' +import test from 'tape' -// https://github.com/feross/webtorrent/issues/196 -test('encode special chars +* in http tracker urls', function (t) { - var q = { - info_hash: new Buffer('a2a15537542b22925ad10486bf7a8b2a9c42f0d1', 'hex').toString('binary') - } - var encoded = 'info_hash=%A2%A1U7T%2B%22%92Z%D1%04%86%BFz%8B%2A%9CB%F0%D1' +// https://github.com/webtorrent/webtorrent/issues/196 +test('encode special chars +* in http tracker urls', t => { + const q = Object.create(null) + q.info_hash = Buffer.from('a2a15537542b22925ad10486bf7a8b2a9c42f0d1', 'hex').toString('binary') + + const encoded = 'info_hash=%A2%A1U7T%2B%22%92Z%D1%04%86%BFz%8B%2A%9CB%F0%D1' t.equal(common.querystringStringify(q), encoded) // sanity check that encode-decode matches up diff --git a/test/request-handler.js b/test/request-handler.js new file mode 100644 index 000000000..6532cbbb8 --- /dev/null +++ b/test/request-handler.js @@ -0,0 +1,73 @@ +import Client from '../index.js' +import common from './common.js' +import fixtures from 'webtorrent-fixtures' +import test from 'tape' +import Server from '../server.js' + +const peerId = Buffer.from('01234567890123456789') + +function testRequestHandler (t, serverType) { + t.plan(5) + + const opts = { serverType } // this is test-suite-only option + + class Swarm extends Server.Swarm { + announce (params, cb) { + super.announce(params, (err, response) => { + if (cb && err) return cb(response) + response.complete = 246 + response.extraData = 'hi' + if (cb) cb(null, response) + }) + } + } + + // Use a custom Swarm implementation for this test only + const OldSwarm = Server.Swarm + Server.Swarm = Swarm + t.on('end', () => { + Server.Swarm = OldSwarm + }) + + common.createServer(t, opts, (server, announceUrl) => { + const client1 = new Client({ + infoHash: fixtures.alice.parsedTorrent.infoHash, + announce: announceUrl, + peerId, + port: 6881, + wrtc: {} + }) + + client1.on('error', err => { t.error(err) }) + if (serverType === 'ws') common.mockWebsocketTracker(client1) + + server.once('start', () => { + t.pass('got start message from client1') + }) + + client1.once('update', data => { + t.equal(data.complete, 246) + t.equal(Buffer.from(data.extraData).toString(), 'hi') + + client1.destroy(() => { + t.pass('client1 destroyed') + }) + + server.close(() => { + t.pass('server destroyed') + }) + }) + + client1.start() + }) +} + +test('http: request handler option intercepts announce requests and responses', t => { + testRequestHandler(t, 'http') +}) + +test('ws: request handler option intercepts announce requests and responses', t => { + testRequestHandler(t, 'ws') +}) + +// 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! diff --git a/test/scrape.js b/test/scrape.js index cd4c40caf..c0c101a5f 100644 --- a/test/scrape.js +++ b/test/scrape.js @@ -1,196 +1,237 @@ -var bencode = require('bencode') -var Client = require('../') -var commonLib = require('../lib/common') -var commonTest = require('./common') -var fs = require('fs') -var get = require('simple-get') -var parseTorrent = require('parse-torrent') -var Server = require('../').Server -var test = require('tape') - -var infoHash1 = 'aaa67059ed6bd08362da625b3ae77f6f4a075aaa' -var binaryInfoHash1 = commonLib.hexToBinary(infoHash1) -var infoHash2 = 'bbb67059ed6bd08362da625b3ae77f6f4a075bbb' -var binaryInfoHash2 = commonLib.hexToBinary(infoHash2) - -var bitlove = fs.readFileSync(__dirname + '/torrents/bitlove-intro.torrent') -var parsedBitlove = parseTorrent(bitlove) -var binaryBitlove = commonLib.hexToBinary(parsedBitlove.infoHash) - -var peerId = new Buffer('01234567890123456789') +import bencode from 'bencode' +import Client from '../index.js' +import common from './common.js' +import commonLib from '../lib/common.js' +import fixtures from 'webtorrent-fixtures' +import fetch from 'cross-fetch-ponyfill' +import test from 'tape' +import { hex2bin } from 'uint8-util' -function testSingle (t, serverType) { - commonTest.createServer(t, serverType, function (server, announceUrl) { - parsedBitlove.announce = [ announceUrl ] - var client = new Client(peerId, 6881, parsedBitlove) +const peerId = Buffer.from('01234567890123456789') - client.on('error', function (err) { - t.error(err) +function testSingle (t, serverType) { + common.createServer(t, serverType, (server, announceUrl) => { + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId, + port: 6881, + wrtc: {} }) - client.on('warning', function (err) { - t.error(err) - }) + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) client.scrape() - client.on('scrape', function (data) { + client.on('scrape', data => { t.equal(data.announce, announceUrl) + t.equal(data.infoHash, fixtures.leaves.parsedTorrent.infoHash) t.equal(typeof data.complete, 'number') t.equal(typeof data.incomplete, 'number') t.equal(typeof data.downloaded, 'number') client.destroy() - server.close(function () { + server.close(() => { t.end() }) }) }) } -test('http: single info_hash scrape', function (t) { +test('http: single info_hash scrape', t => { testSingle(t, 'http') }) -test('udp: single info_hash scrape', function (t) { +test('udp: single info_hash scrape', t => { testSingle(t, 'udp') }) +test('ws: single info_hash scrape', t => { + testSingle(t, 'ws') +}) + function clientScrapeStatic (t, serverType) { - commonTest.createServer(t, serverType, function (server, announceUrl) { - Client.scrape(announceUrl, infoHash1, function (err, data) { + common.createServer(t, serverType, (server, announceUrl) => { + const client = Client.scrape({ + announce: announceUrl, + infoHash: fixtures.leaves.parsedTorrent.infoHash, + wrtc: {} + }, (err, data) => { t.error(err) t.equal(data.announce, announceUrl) + t.equal(data.infoHash, fixtures.leaves.parsedTorrent.infoHash) t.equal(typeof data.complete, 'number') t.equal(typeof data.incomplete, 'number') t.equal(typeof data.downloaded, 'number') - server.close(function () { + server.close(() => { t.end() }) }) + if (serverType === 'ws') common.mockWebsocketTracker(client) }) } -test('http: scrape using Client.scrape static method', function (t) { +test('http: scrape using Client.scrape static method', t => { clientScrapeStatic(t, 'http') }) -test('udp: scrape using Client.scrape static method', function (t) { +test('udp: scrape using Client.scrape static method', t => { clientScrapeStatic(t, 'udp') }) +test('ws: scrape using Client.scrape static method', t => { + clientScrapeStatic(t, 'ws') +}) + +// Ensure the callback function gets called when an invalid url is passed +function clientScrapeStaticInvalid (t, serverType) { + let announceUrl = `${serverType}://invalid.lol` + if (serverType === 'http') announceUrl += '/announce' + + const client = Client.scrape({ + announce: announceUrl, + infoHash: fixtures.leaves.parsedTorrent.infoHash, + wrtc: {} + }, (err, data) => { + t.ok(err instanceof Error) + t.end() + }) + if (serverType === 'ws') common.mockWebsocketTracker(client) +} + +test('http: scrape using Client.scrape static method (invalid url)', t => { + clientScrapeStaticInvalid(t, 'http') +}) + +test('udp: scrape using Client.scrape static method (invalid url)', t => { + clientScrapeStaticInvalid(t, 'udp') +}) + +test('ws: scrape using Client.scrape static method (invalid url)', t => { + clientScrapeStaticInvalid(t, 'ws') +}) + function clientScrapeMulti (t, serverType) { - commonTest.createServer(t, serverType, function (server, announceUrl) { - Client.scrape(announceUrl, [ infoHash1, infoHash2 ], function (err, results) { + const infoHash1 = fixtures.leaves.parsedTorrent.infoHash + const infoHash2 = fixtures.alice.parsedTorrent.infoHash + + common.createServer(t, serverType, (server, announceUrl) => { + Client.scrape({ + infoHash: [infoHash1, infoHash2], + announce: announceUrl + }, (err, results) => { t.error(err) t.equal(results[infoHash1].announce, announceUrl) + t.equal(results[infoHash1].infoHash, infoHash1) t.equal(typeof results[infoHash1].complete, 'number') t.equal(typeof results[infoHash1].incomplete, 'number') t.equal(typeof results[infoHash1].downloaded, 'number') t.equal(results[infoHash2].announce, announceUrl) + t.equal(results[infoHash2].infoHash, infoHash2) t.equal(typeof results[infoHash2].complete, 'number') t.equal(typeof results[infoHash2].incomplete, 'number') t.equal(typeof results[infoHash2].downloaded, 'number') - server.close(function () { + server.close(() => { t.end() }) }) }) } -test('http: MULTI scrape using Client.scrape static method', function (t) { +test('http: MULTI scrape using Client.scrape static method', t => { clientScrapeMulti(t, 'http') }) -test('udp: MULTI scrape using Client.scrape static method', function (t) { +test('udp: MULTI scrape using Client.scrape static method', t => { clientScrapeMulti(t, 'udp') }) -test('server: multiple info_hash scrape (manual http request)', function (t) { - var server = new Server({ udp: false }) - server.on('error', function (err) { - t.error(err) - }) - server.on('warning', function (err) { - t.error(err) - }) +test('server: multiple info_hash scrape (manual http request)', t => { + t.plan(12) - server.listen(0, function () { - var port = server.http.address().port - var scrapeUrl = 'http://127.0.0.1:' + port + '/scrape' - var url = scrapeUrl + '?' + commonLib.querystringStringify({ - info_hash: [ binaryInfoHash1, binaryInfoHash2 ] - }) - get.concat(url, function (err, data, res) { - if (err) throw err - t.equal(res.statusCode, 200) + const binaryInfoHash1 = hex2bin(fixtures.leaves.parsedTorrent.infoHash) + const binaryInfoHash2 = hex2bin(fixtures.alice.parsedTorrent.infoHash) - data = bencode.decode(data) - t.ok(data.files) - t.equal(Object.keys(data.files).length, 2) + common.createServer(t, 'http', async (server, announceUrl) => { + const scrapeUrl = announceUrl.replace('/announce', '/scrape') - t.ok(data.files[binaryInfoHash1]) - t.equal(typeof data.files[binaryInfoHash1].complete, 'number') - t.equal(typeof data.files[binaryInfoHash1].incomplete, 'number') - t.equal(typeof data.files[binaryInfoHash1].downloaded, 'number') + const url = `${scrapeUrl}?${commonLib.querystringStringify({ + info_hash: [binaryInfoHash1, binaryInfoHash2] +})}` + let res + try { + res = await fetch(url) + } catch (err) { + t.error(err) + } + let data = Buffer.from(await res.arrayBuffer()) - t.ok(data.files[binaryInfoHash2]) - t.equal(typeof data.files[binaryInfoHash2].complete, 'number') - t.equal(typeof data.files[binaryInfoHash2].incomplete, 'number') - t.equal(typeof data.files[binaryInfoHash2].downloaded, 'number') + t.equal(res.status, 200) - server.close(function () { - t.end() - }) - }) + data = bencode.decode(data) + t.ok(data.files) + t.equal(Object.keys(data.files).length, 2) + + t.ok(data.files[binaryInfoHash1]) + t.equal(typeof data.files[binaryInfoHash1].complete, 'number') + t.equal(typeof data.files[binaryInfoHash1].incomplete, 'number') + t.equal(typeof data.files[binaryInfoHash1].downloaded, 'number') + + t.ok(data.files[binaryInfoHash2]) + t.equal(typeof data.files[binaryInfoHash2].complete, 'number') + t.equal(typeof data.files[binaryInfoHash2].incomplete, 'number') + t.equal(typeof data.files[binaryInfoHash2].downloaded, 'number') + + server.close(() => { t.pass('server closed') }) }) }) -test('server: all info_hash scrape (manual http request)', function (t) { - var server = new Server({ udp: false }) - server.on('error', function (err) { - t.error(err) - }) - server.on('warning', function (err) { - t.error(err) - }) +test('server: all info_hash scrape (manual http request)', t => { + t.plan(9) - server.listen(0, function () { - var port = server.http.address().port - var announceUrl = 'http://127.0.0.1:' + port + '/announce' - var scrapeUrl = 'http://127.0.0.1:' + port + '/scrape' + const binaryInfoHash = hex2bin(fixtures.leaves.parsedTorrent.infoHash) - parsedBitlove.announce = [ announceUrl ] + common.createServer(t, 'http', (server, announceUrl) => { + const scrapeUrl = announceUrl.replace('/announce', '/scrape') // announce a torrent to the tracker - var client = new Client(peerId, port, parsedBitlove) - client.on('error', function (err) { - t.error(err) + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId, + port: 6881 }) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) + client.start() - server.once('start', function () { + server.once('start', async () => { // now do a scrape of everything by omitting the info_hash param - get.concat(scrapeUrl, function (err, data, res) { - if (err) throw err - - t.equal(res.statusCode, 200) - data = bencode.decode(data) - t.ok(data.files) - t.equal(Object.keys(data.files).length, 1) - - t.ok(data.files[binaryBitlove]) - t.equal(typeof data.files[binaryBitlove].complete, 'number') - t.equal(typeof data.files[binaryBitlove].incomplete, 'number') - t.equal(typeof data.files[binaryBitlove].downloaded, 'number') - - client.destroy() - server.close(function () { - t.end() - }) - }) + let res + try { + res = await fetch(scrapeUrl) + } catch (err) { + t.error(err) + } + let data = Buffer.from(await res.arrayBuffer()) + + t.equal(res.status, 200) + data = bencode.decode(data) + t.ok(data.files) + t.equal(Object.keys(data.files).length, 1) + + t.ok(data.files[binaryInfoHash]) + t.equal(typeof data.files[binaryInfoHash].complete, 'number') + t.equal(typeof data.files[binaryInfoHash].incomplete, 'number') + t.equal(typeof data.files[binaryInfoHash].downloaded, 'number') + + client.destroy(() => { t.pass('client destroyed') }) + server.close(() => { t.pass('server closed') }) }) }) }) diff --git a/test/server.js b/test/server.js index ef1253ec7..d4cf493e4 100644 --- a/test/server.js +++ b/test/server.js @@ -1,113 +1,161 @@ -var Client = require('../') -var Server = require('../').Server -var test = require('tape') +import Client from '../index.js' +import common from './common.js' +import test from 'tape' +import wrtc from 'webrtc-polyfill' -var infoHash = '4cb67059ed6bd08362da625b3ae77f6f4a075705' -var peerId = new Buffer('01234567890123456789') -var peerId2 = new Buffer('12345678901234567890') -var torrentLength = 50000 +const infoHash = '4cb67059ed6bd08362da625b3ae77f6f4a075705' +const peerId = Buffer.from('01234567890123456789') +const peerId2 = Buffer.from('12345678901234567890') +const peerId3 = Buffer.from('23456789012345678901') function serverTest (t, serverType, serverFamily) { - t.plan(25) - - var opts = serverType === 'http' ? { udp: false } : { http: false } - var server = new Server(opts) - var serverAddr = serverFamily === 'inet6' ? '[::1]' : '127.0.0.1' - var clientAddr = serverFamily === 'inet6' ? '[::1]' : '127.0.0.1' - var clientIp = serverFamily === 'inet6' ? '::1' : '127.0.0.1' - - server.on('error', function (err) { - t.fail(err.message) - }) - - server.on('warning', function (err) { - t.fail(err.message) - }) - - server.on('listening', function () { - t.pass('server listening') - }) - - server.listen(0, function () { - var port = server[serverType].address().port - var announceUrl = serverType + '://' + serverAddr + ':' + port + '/announce' - - var client1 = new Client(peerId, 6881, { - infoHash: infoHash, - length: torrentLength, - announce: [ announceUrl ] + t.plan(40) + + const hostname = serverFamily === 'inet6' + ? '[::1]' + : '127.0.0.1' + const clientIp = serverFamily === 'inet6' + ? '::1' + : '127.0.0.1' + + const opts = { + serverType + } + + common.createServer(t, opts, server => { + // Not using announceUrl param from `common.createServer()` since we + // want to control IPv4 vs IPv6. + const port = server[serverType].address().port + const announceUrl = `${serverType}://${hostname}:${port}/announce` + + const client1 = new Client({ + infoHash, + announce: [announceUrl], + peerId, + port: 6881, + wrtc }) + if (serverType === 'ws') common.mockWebsocketTracker(client1) client1.start() - server.once('start', function () { + server.once('start', () => { t.pass('got start message from client1') }) - client1.once('update', function (data) { + client1.once('update', data => { t.equal(data.announce, announceUrl) t.equal(data.complete, 0) t.equal(data.incomplete, 1) - var swarm = server.getSwarm(infoHash) - - t.equal(Object.keys(server.torrents).length, 1) - t.equal(swarm.complete, 0) - t.equal(swarm.incomplete, 1) - t.equal(Object.keys(swarm.peers).length, 1) - t.deepEqual(swarm.peers[clientAddr + ':6881'], { - ip: clientIp, - port: 6881, - peerId: peerId.toString('hex'), - complete: false, - socket: undefined - }) - - client1.complete() - - client1.once('update', function (data) { - t.equal(data.announce, announceUrl) - t.equal(data.complete, 1) - t.equal(data.incomplete, 0) - - client1.scrape() - - client1.once('scrape', function (data) { + server.getSwarm(infoHash, (err, swarm) => { + t.error(err) + + t.equal(Object.keys(server.torrents).length, 1) + t.equal(swarm.complete, 0) + t.equal(swarm.incomplete, 1) + t.equal(swarm.peers.length, 1) + + const id = serverType === 'ws' + ? peerId.toString('hex') + : `${hostname}:6881` + + const peer = swarm.peers.peek(id) + t.equal(peer.type, serverType) + t.equal(peer.ip, clientIp) + t.equal(peer.peerId, peerId.toString('hex')) + t.equal(peer.complete, false) + if (serverType === 'ws') { + t.equal(typeof peer.port, 'number') + t.ok(peer.socket) + } else { + t.equal(peer.port, 6881) + t.notOk(peer.socket) + } + + client1.complete() + + client1.once('update', data => { t.equal(data.announce, announceUrl) - t.equal(typeof data.complete, 'number') - t.equal(typeof data.incomplete, 'number') - t.equal(typeof data.downloaded, 'number') - - var client2 = new Client(peerId2, 6882, { - infoHash: infoHash, - length: torrentLength, - announce: [ announceUrl ] - }) + t.equal(data.complete, 1) + t.equal(data.incomplete, 0) + + client1.scrape() + + client1.once('scrape', data => { + t.equal(data.announce, announceUrl) + t.equal(data.complete, 1) + t.equal(data.incomplete, 0) + t.equal(typeof data.downloaded, 'number') + + const client2 = new Client({ + infoHash, + announce: [announceUrl], + peerId: peerId2, + port: 6882, + wrtc + }) + if (serverType === 'ws') common.mockWebsocketTracker(client2) - client2.start() + client2.start() - server.once('start', function () { - t.pass('got start message from client2') - }) - - client2.once('peer', function (addr) { - t.ok(addr === clientAddr + ':6881' || addr === clientAddr + ':6882') + server.once('start', () => { + t.pass('got start message from client2') + }) - client2.stop() - client2.once('update', function (data) { + client2.once('update', data => { t.equal(data.announce, announceUrl) t.equal(data.complete, 1) - t.equal(data.incomplete, 0) - client2.destroy() + t.equal(data.incomplete, 1) + + const client3 = new Client({ + infoHash, + announce: [announceUrl], + peerId: peerId3, + port: 6880, + wrtc + }) + if (serverType === 'ws') common.mockWebsocketTracker(client3) - client1.stop() - client1.once('update', function (data) { - t.equal(data.announce, announceUrl) - t.equal(data.complete, 0) - t.equal(data.incomplete, 0) + client3.start() + + server.once('start', () => { + t.pass('got start message from client3') + }) - client1.destroy() - server.close() + client3.once('update', data => { + t.equal(data.announce, announceUrl) + t.equal(data.complete, 1) + t.equal(data.incomplete, 2) + + client2.stop() + client2.once('update', data => { + t.equal(data.announce, announceUrl) + t.equal(data.complete, 1) + t.equal(data.incomplete, 1) + + client2.destroy(() => { + t.pass('client2 destroyed') + client3.stop() + client3.once('update', data => { + t.equal(data.announce, announceUrl) + t.equal(data.complete, 1) + t.equal(data.incomplete, 0) + + client1.destroy(() => { + t.pass('client1 destroyed') + }) + + client3.destroy(() => { + t.pass('client3 destroyed') + }) + + server.close(() => { + t.pass('server destroyed') + }) + }) + }) + }) }) }) }) @@ -117,14 +165,18 @@ function serverTest (t, serverType, serverFamily) { }) } -test('http ipv4 server', function (t) { +test('http ipv4 server', t => { serverTest(t, 'http', 'inet') }) -test('http ipv6 server', function (t) { +test('http ipv6 server', t => { serverTest(t, 'http', 'inet6') }) -test('udp server', function (t) { +test('udp server', t => { serverTest(t, 'udp', 'inet') }) + +test('ws server', t => { + serverTest(t, 'ws', 'inet') +}) diff --git a/test/stats.js b/test/stats.js new file mode 100644 index 000000000..7223da014 --- /dev/null +++ b/test/stats.js @@ -0,0 +1,197 @@ +import Client from '../index.js' +import commonTest from './common.js' +import fixtures from 'webtorrent-fixtures' +import fetch from 'cross-fetch-ponyfill' +import test from 'tape' + +const peerId = Buffer.from('-WW0091-4ea5886ce160') +const unknownPeerId = Buffer.from('01234567890123456789') + +function parseHtml (html) { + const extractValue = /[^v^h](\d+)/ + const array = html.replace('torrents', '\n').split('\n').filter(line => line && line.trim().length > 0).map(line => { + const a = extractValue.exec(line) + if (a) { + return parseInt(a[1]) + } + return null + }) + let i = 0 + return { + torrents: array[i++], + activeTorrents: array[i++], + peersAll: array[i++], + peersSeederOnly: array[i++], + peersLeecherOnly: array[i++], + peersSeederAndLeecher: array[i++], + peersIPv4: array[i++], + peersIPv6: array[i] + } +} + +test('server: get empty stats', t => { + t.plan(10) + + commonTest.createServer(t, 'http', async (server, announceUrl) => { + const url = announceUrl.replace('/announce', '/stats') + + let res + try { + res = await fetch(url) + } catch (err) { + t.error(err) + } + const data = Buffer.from(await res.arrayBuffer()) + + const stats = parseHtml(data.toString()) + t.equal(res.status, 200) + t.equal(stats.torrents, 0) + t.equal(stats.activeTorrents, 0) + t.equal(stats.peersAll, 0) + t.equal(stats.peersSeederOnly, 0) + t.equal(stats.peersLeecherOnly, 0) + t.equal(stats.peersSeederAndLeecher, 0) + t.equal(stats.peersIPv4, 0) + t.equal(stats.peersIPv6, 0) + + server.close(() => { t.pass('server closed') }) + }) +}) + +test('server: get empty stats with json header', t => { + t.plan(10) + + commonTest.createServer(t, 'http', async (server, announceUrl) => { + const opts = { + url: announceUrl.replace('/announce', '/stats'), + headers: { + accept: 'application/json' + } + } + let res + try { + res = await fetch(announceUrl.replace('/announce', '/stats'), opts) + } catch (err) { + t.error(err) + } + const stats = await res.json() + + t.equal(res.status, 200) + t.equal(stats.torrents, 0) + t.equal(stats.activeTorrents, 0) + t.equal(stats.peersAll, 0) + t.equal(stats.peersSeederOnly, 0) + t.equal(stats.peersLeecherOnly, 0) + t.equal(stats.peersSeederAndLeecher, 0) + t.equal(stats.peersIPv4, 0) + t.equal(stats.peersIPv6, 0) + + server.close(() => { t.pass('server closed') }) + }) +}) + +test('server: get empty stats on stats.json', t => { + t.plan(10) + + commonTest.createServer(t, 'http', async (server, announceUrl) => { + let res + try { + res = await fetch(announceUrl.replace('/announce', '/stats.json')) + } catch (err) { + t.error(err) + } + const stats = await res.json() + + t.equal(res.status, 200) + t.equal(stats.torrents, 0) + t.equal(stats.activeTorrents, 0) + t.equal(stats.peersAll, 0) + t.equal(stats.peersSeederOnly, 0) + t.equal(stats.peersLeecherOnly, 0) + t.equal(stats.peersSeederAndLeecher, 0) + t.equal(stats.peersIPv4, 0) + t.equal(stats.peersIPv6, 0) + + server.close(() => { t.pass('server closed') }) + }) +}) + +test('server: get leecher stats.json', t => { + t.plan(10) + + commonTest.createServer(t, 'http', (server, announceUrl) => { + // announce a torrent to the tracker + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId, + port: 6881 + }) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) + + client.start() + + server.once('start', async () => { + let res + try { + res = await fetch(announceUrl.replace('/announce', '/stats.json')) + } catch (err) { + t.error(err) + } + const stats = await res.json() + + t.equal(res.status, 200) + t.equal(stats.torrents, 1) + t.equal(stats.activeTorrents, 1) + t.equal(stats.peersAll, 1) + t.equal(stats.peersSeederOnly, 0) + t.equal(stats.peersLeecherOnly, 1) + t.equal(stats.peersSeederAndLeecher, 0) + t.equal(stats.clients.WebTorrent['0.91'], 1) + + client.destroy(() => { t.pass('client destroyed') }) + server.close(() => { t.pass('server closed') }) + }) + }) +}) + +test('server: get leecher stats.json (unknown peerId)', t => { + t.plan(10) + + commonTest.createServer(t, 'http', (server, announceUrl) => { + // announce a torrent to the tracker + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: unknownPeerId, + port: 6881 + }) + client.on('error', err => { t.error(err) }) + client.on('warning', err => { t.error(err) }) + + client.start() + + server.once('start', async () => { + let res + try { + res = await fetch(announceUrl.replace('/announce', '/stats.json')) + } catch (err) { + t.error(err) + } + const stats = await res.json() + + t.equal(res.status, 200) + t.equal(stats.torrents, 1) + t.equal(stats.activeTorrents, 1) + t.equal(stats.peersAll, 1) + t.equal(stats.peersSeederOnly, 0) + t.equal(stats.peersLeecherOnly, 1) + t.equal(stats.peersSeederAndLeecher, 0) + t.equal(stats.clients.unknown['01234567'], 1) + + client.destroy(() => { t.pass('client destroyed') }) + server.close(() => { t.pass('server closed') }) + }) + }) +}) diff --git a/test/torrents/bitlove-intro.torrent b/test/torrents/bitlove-intro.torrent deleted file mode 100644 index 1c91bf136..000000000 Binary files a/test/torrents/bitlove-intro.torrent and /dev/null differ diff --git a/test/torrents/leaves.torrent b/test/torrents/leaves.torrent deleted file mode 100644 index f18e7c113..000000000 Binary files a/test/torrents/leaves.torrent and /dev/null differ diff --git a/test/torrents/sintel-5gb.torrent b/test/torrents/sintel-5gb.torrent deleted file mode 100644 index 65f43c4d3..000000000 Binary files a/test/torrents/sintel-5gb.torrent and /dev/null differ diff --git a/tools/update-authors.sh b/tools/update-authors.sh new file mode 100755 index 000000000..ef80c638a --- /dev/null +++ b/tools/update-authors.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Update AUTHORS.md based on git history. + +git log --reverse --format='%aN (%aE)' | perl -we ' +BEGIN { + %seen = (), @authors = (); +} +while (<>) { + next if $seen{$_}; + next if /(support\@greenkeeper.io)/; + next if /(yoann\@atacma.agency)/; + next if /(yciabaud\@users.noreply.github.com)/; + next if /(DiegoRBaquero\@users.noreply.github.com)/; + next if /(gustavcaplan\@gmail.com)/; + $seen{$_} = push @authors, "- ", $_; +} +END { + print "# Authors\n\n"; + print "#### Ordered by first contribution.\n\n"; + print @authors, "\n"; + print "#### Generated by tools/update-authors.sh.\n"; +} +' > AUTHORS.md