diff --git a/index.js b/index.js index 7466a9e5..c5c70280 100644 --- a/index.js +++ b/index.js @@ -95,10 +95,12 @@ class WebTorrent extends EventEmitter { } } + this._natTraversal = require('./lib/nat-traversal') // browser exclude + if (typeof TCPPool === 'function') { this._tcpPool = new TCPPool(this) } else { - process.nextTick(() => { + process.nextTick(function () { this._onListening() }) } @@ -117,7 +119,12 @@ class WebTorrent extends EventEmitter { this.dht.once('listening', () => { const address = this.dht.address() - if (address) this.dhtPort = address.port + if (address) { + this.dhtPort = address.port + if (this._natTraversal.portMapping) { + this._natTraversal.portMapping(this.dhtPort, 'udp') + } + } }) // Ignore warning when there are > 10 torrents in the client @@ -377,6 +384,12 @@ class WebTorrent extends EventEmitter { }) } + if (this._natTraversal.destroy) { + tasks.push(cb => { + this._natTraversal.destroy(cb) + }) + } + parallel(tasks, cb) if (err) this.emit('error', err) @@ -392,11 +405,15 @@ class WebTorrent extends EventEmitter { if (this._tcpPool) { // Sometimes server.address() returns `null` in Docker. - const address = this._tcpPool.server.address() - if (address) this.torrentPort = address.port + var address = this._tcpPool.server.address() + if (address) { + this.torrentPort = address.port + if (this._natTraversal.portMapping) { + this._natTraversal.portMapping(this.torrentPort, 'tcp') + } + } + this.emit('listening') } - - this.emit('listening') } _debug () { diff --git a/lib/nat-traversal.js b/lib/nat-traversal.js new file mode 100644 index 00000000..dbfcc8b0 --- /dev/null +++ b/lib/nat-traversal.js @@ -0,0 +1,161 @@ +var arrayRemove = require('unordered-array-remove') +var debug = require('debug')('webtorrent:nat-traversal') +var natUpnp = require('nat-upnp') // browser exclude +var natpmp = require('nat-pmp') // browser exclude +var network = require('network') // browser exclude + +// Use single instance +module.exports = new NatTraversal() + +function NatTraversal () { + var self = this + self._destroyed = false + + // The RECOMMENDED Port Mapping Lifetime is 7200 seconds (two hours). + self.ttl = 7200 + // Refresh the mapping 10 minutes before the end of its lifetime + self.timeout = (self.ttl - 600) * 1000 + self._openedPorts = [] + self._intervalsUpnp = {} + self._intervalsPmp = {} + + self._upnpPortMapping = function (port, protocol, cb) { + var self = this + + debug('Mapping port %d for protocol %s on router using UPnP', port, protocol) + self._upnpClient.portMapping({ + public: port, + private: port, + description: 'WebTorrent', + protocol: protocol, + ttl: self.ttl + }, function (err) { + if (self._destroyed) return typeof cb === 'function' && cb() + if (err) { + return typeof cb === 'function' && cb(err) + } + self._intervalsUpnp[port] = setInterval(self._pmpPortMapping.bind(self, port, protocol), self.timeout) + debug('Port %d for protocol %s mapped on router using UPnP', port, protocol) + if (typeof cb === 'function') cb() + }) + } + + self._pmpPortMapping = function (port, protocol, cb) { + var self = this + + debug('Mapping port %d for protocol %s on router using NAT-PMP', port, protocol) + self._pmpClient.portMapping({ + private: port, + public: port, + ttl: self.ttl, + type: protocol + }, function (err/* , info */) { + if (self._destroyed) return typeof cb === 'function' && cb() + if (err) { + debug('Error mapping port %d using NAT-PMP', port, err) + return typeof cb === 'function' && cb(err) + } + self._intervalsPmp[port] = setInterval(self._pmpPortMapping.bind(self, port, protocol), self.timeout) + debug('Port %d for protocol %s mapped on router using NAT-PMP', port, protocol) + if (typeof cb === 'function') cb() + }) + } + + debug('UPnP client creation') + self._upnpClient = natUpnp.createClient() + + // Lookup gateway IP + debug('Lookup gateway IP') + network.get_gateway_ip(function (err, ip) { + if (self._destroyed) return + if (err) { + return debug('Could not find gateway IP for NAT-PMP', err) + } + debug('NAT-PMP client creation', ip) + self._pmpClient = natpmp.connect(ip) + self._openedPorts.forEach(function (obj) { + self._pmpPortMapping(obj.port, obj.protocol) + }) + }) +} + +NatTraversal.prototype.portMapping = function (port, protocol, cb) { + var self = this + if (self._destroyed) return typeof c === 'function' && cb() + if (typeof protocol === 'function') { + cb = protocol + protocol = 'tcp' + } + + self._openedPorts.push({ port: port, protocol: protocol }) + + // Try UPnP first + self._upnpPortMapping(port, protocol, function (err) { + if (self._destroyed) return typeof cb === 'function' && cb() + if (err) { + debug('UPnP port mapping failed on %d', port, err.message) + } + + // Then NAT-PMP + if (self._pmpClient) { + self._pmpPortMapping(port, protocol, cb) + } else if (typeof cb === 'function') { + cb() + } + }) +} + +NatTraversal.prototype.portUnMapping = function (port, cb) { + var self = this + if (self._destroyed) return typeof cb === 'function' && cb() + arrayRemove(self._openedPorts, self._openedPorts.findIndex(o => o.port === port)) + + // Clear intervals + if (self._intervalsUpnp[port]) { + clearInterval(self._intervalsUpnp[port]) + delete self._intervalsUpnp[port] + } + if (self._intervalsPmp[port]) { + clearInterval(self._intervalsPmp[port]) + delete self._intervalsPmp[port] + } + + debug('Unmapping port %d on router using UPnP', port) + self._upnpClient.portUnmapping({ + public: port + }, function (err) { + if (!err) debug('Port %d unmapped on router using UPnP', port) + if (self._pmpClient) { + debug('Unmapping port %d on router using NAT-PMP', port) + self._pmpClient.portUnmapping({ + private: port, + public: port + }, cb) + } else { + if (typeof cb === 'function') cb() + } + }) +} + +NatTraversal.prototype.destroy = function (cb) { + var self = this + if (self._destroyed) return cb() + + // Unmap all ports + self._openedPorts.forEach(function (obj) { + self.portUnMapping(obj.port) + }) + self._destroyed = true + + if (self._pmpClient) { + debug('Close pmp client') + self._pmpClient.close() + } + + // Waiting next tick to prevent breaking some sockets + process.nextTick(function () { + debug('Close UPnP client') + self._upnpClient.close() + cb() + }) +} diff --git a/lib/server.js b/lib/server.js index 60595a8a..5b3622b7 100644 --- a/lib/server.js +++ b/lib/server.js @@ -7,6 +7,7 @@ const url = require('url') function Server (torrent, opts = {}) { const server = http.createServer() + const natTraversal = require('./nat-traversal') if (!opts.origin) opts.origin = '*' // allow all origins by default const sockets = [] @@ -15,6 +16,7 @@ function Server (torrent, opts = {}) { server.on('connection', onConnection) server.on('request', onRequest) + server.on('listening', onListening) const _close = server.close server.close = cb => { @@ -34,6 +36,10 @@ function Server (torrent, opts = {}) { socket.destroy() }) + if (server.address()) { + natTraversal.portUnMapping(server.address().port) + } + // Only call `server.close` if user has not called it already if (!cb) cb = () => {} if (closed) process.nextTick(cb) @@ -214,6 +220,10 @@ function Server (torrent, opts = {}) { } } + function onListening () { + natTraversal.portMapping(server.address().port) + } + return server } diff --git a/package.json b/package.json index 7bb34c8b..c087ea1a 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,16 @@ "url": "https://webtorrent.io" }, "browser": { + "./lib/nat-traversal.js": false, "./lib/server.js": false, "./lib/tcp-pool.js": false, "bittorrent-dht/client": false, "fs-chunk-store": "memory-chunk-store", "load-ip-set": false, + "nat-pmp": false, + "nat-upnp": false, "net": false, + "network": false, "os": false, "ut_pex": false }, @@ -40,6 +44,9 @@ "memory-chunk-store": "^1.2.0", "mime": "^2.2.0", "multistream": "^2.0.5", + "nat-pmp": "github:yciabaud/node-nat-pmp#patch-1", + "nat-upnp": "^1.0.4", + "network": "^0.3.2", "package-json-versionify": "^1.0.2", "parse-numeric-range": "^0.0.2", "parse-torrent": "^6.1.2",