From b6267f27bc428c40543a94cd6c9aba005f0c9e80 Mon Sep 17 00:00:00 2001 From: Dmitriy Ryajov Date: Wed, 23 May 2018 23:10:04 -0600 Subject: [PATCH 01/11] feat: Initial commit --- appveyor.yml | 29 ++++ circle.yml | 22 +++ package.json | 22 +++ src/index.js | 66 ++++++++ src/mappers/nat-pmp.js | 72 +++++++++ src/mappers/pcp.js | 310 +++++++++++++++++++++++++++++++++++ src/mappers/upnp.js | 356 +++++++++++++++++++++++++++++++++++++++++ src/utils.js | 223 ++++++++++++++++++++++++++ 8 files changed, 1100 insertions(+) create mode 100644 appveyor.yml create mode 100644 circle.yml create mode 100644 package.json create mode 100644 src/index.js create mode 100644 src/mappers/nat-pmp.js create mode 100644 src/mappers/pcp.js create mode 100644 src/mappers/upnp.js create mode 100644 src/utils.js diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..046bf91 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,29 @@ +# Warning: This file is automatically synced from https://github.com/ipfs/ci-sync so if you want to change it, please change it there and ask someone to sync all repositories. +version: "{build}" + +environment: + matrix: + - nodejs_version: "6" + - nodejs_version: "8" + +matrix: + fast_finish: true + +install: + # Install Node.js + - ps: Install-Product node $env:nodejs_version + + # Upgrade npm + - npm install -g npm + + # Output our current versions for debugging + - node --version + - npm --version + + # Install our package dependencies + - npm install + +test_script: + - npm run test:node + +build: off diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..445e40b --- /dev/null +++ b/circle.yml @@ -0,0 +1,22 @@ +# Warning: This file is automatically synced from https://github.com/ipfs/ci-sync so if you want to change it, please change it there and ask someone to sync all repositories. +machine: + node: + version: stable + +test: + pre: + - npm run lint + post: + - make test + - npm run coverage -- --upload --providers coveralls + +dependencies: + pre: + - google-chrome --version + - curl -L -o google-chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb + - sudo dpkg -i google-chrome.deb || true + - sudo apt-get update + - sudo apt-get install -f + - sudo apt-get install --only-upgrade lsb-base + - sudo dpkg -i google-chrome.deb + - google-chrome --version diff --git a/package.json b/package.json new file mode 100644 index 0000000..431b7f1 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "js-libp2p-nat-mgr", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "": "^2.6.1", + "dgram": "^1.0.1", + "ipaddr.js": "^1.7.0", + "nat-pmp": "^1.0.0" + }, + "devDependencies": { + "aegir": "^13.1.0", + "chai": "^4.1.2", + "dirty-chai": "^2.0.1" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..8da60bf --- /dev/null +++ b/src/index.js @@ -0,0 +1,66 @@ +'use strict' + +const utils = require('./utils') +const NatPmp = require('./mappers/nat-pmp') +// const PCP = require('./pcp') +// const UPnP = require('./upnp') +const EE = require('events') +const tryEach = require('async/tryEach') + +class NatManager extends EE { + constructor () { + super() + + this.mappers = [ + new NatPmp() + ] + + this.activeMappings = {} + this.routerIpCache = new Set() + } + + addMapping (intPort, extPort, lifetime) { + tryEach(this.mappers.map((mapper) => { + return (cb) => { + return mapper.addMapping(intPort, + extPort, + lifetime, + this.activeMappings, + this.routerIpCache) + } + })) + } + + deleteMapping (extPort, callback) { + const mapper = this.activeMappings[extPort] + if (mapper) { + mapper.deleteMapping(extPort, + this.routerIpCache, + callback) + } + } + + getActiveMappings () { + return this.activeMappings + } + + getRouterIpCache () { + return this.routerIpCache + } + + getPrivateIps () { + return utils.getPrivateIps() + } + + close () { + return new Promise((resolve, reject) => { + for (let [extPort, mapper] of this.activeMappings) { + mapper.deleteMapping(extPort, + this.activeMappings, + this.routerIpCache) + } + }) + } +} + +module.exports = NatManager diff --git a/src/mappers/nat-pmp.js b/src/mappers/nat-pmp.js new file mode 100644 index 0000000..8345b24 --- /dev/null +++ b/src/mappers/nat-pmp.js @@ -0,0 +1,72 @@ +'use strict' + +const natPmp = require('nat-pmp') +const waterfall = require('async/waterfall') + +const Mapper = require('./mapper') +const utils = require('../utils') + +const NAT_PMP_PROBE_PORT = 55555 + +class NatPMP extends Mapper { + constructor () { + super('natPmp', NAT_PMP_PROBE_PORT) + } + + /** + * Create a port mapping + * + * @param {String} routerIp + * @param {Number} intPort + * @param {Number} extPort + * @param {Number} ttl + * @param {Function} callback + */ + createMapping (routerIp, intPort, extPort, ttl, callback) { + const client = natPmp.connect(routerIp) + waterfall([ + (cb) => client.externalIp((err, info) => { + if (err) { + return callback(err) + } + const mapping = this.newMapping(intPort) + mapping.externalIp = info.ip.join('.') + cb(null, mapping) + }), + (mapping, cb) => { + client.portMapping({ private: intPort, public: extPort, ttl }, (err, info) => { + if (err) { + this.log.err(err) + return cb(err) + } + + mapping.externalPort = info.public + mapping.internalPort = info.private + mapping.ttl = info.ttl + // get the internal ip of the interface + // we're using to make the request + const internalIp = utils.longestPrefixMatch(utils.getPrivateIps(), routerIp) + mapping.internalIp = internalIp + cb(null, mapping) + }) + } + ], (err, mapping) => { + if (err) { + return callback(err) + } + callback(null, mapping) + }) + } + + deleteMapping (intPort, routerIp, extPort, callback) { + const client = natPmp.connect(routerIp) + client.portUnmapping({private: intPort, public: extPort}, (err, info) => { + if (err) { + return callback(err) + } + return callback(null, err) + }) + } +} + +module.exports = NatPMP diff --git a/src/mappers/pcp.js b/src/mappers/pcp.js new file mode 100644 index 0000000..12865d8 --- /dev/null +++ b/src/mappers/pcp.js @@ -0,0 +1,310 @@ +'use strict' + +const utils = require('./utils') +const ipaddr = require('ipaddr.js') +const dgram = require('dgram') + +const PCP_PROBE_PORT = 55556 + +class PCP { + /** + * Probe if PCP is supported by the router + * + * @param {object} activeMappings Table of active Mappings + * @param {Array} routerIpCache Router IPs that have previously worked + * @return {Promise} A promise for a boolean + */ + probeSupport (activeMappings, routerIpCache) { + const mapping = this.addMapping(utils.PCP_PROBE_PORT, + utils.PCP_PROBE_PORT, + 120, + activeMappings, + routerIpCache) + return mapping.externalPort !== -1 + } + + /** + * Makes a port mapping in the NAT with PCP, + * and automatically refresh the mapping every two minutes + * + * @param {number} intPort The internal port on the computer to map to + * @param {number} extPort The external port on the router to map to + * @param {number} lifetime Seconds that the mapping will last + * 0 is infinity, i.e. a refresh every 24 hours + * @param {object} activeMappings Table of active Mappings + * @param {Array} routerIpCache Router IPs that have previously worked + * @return {Promise} A promise for the port mapping object + * mapping.externalPort is -1 on failure + */ + addMapping (intPort, extPort, lifetime, activeMappings, routerIpCache) { + const mapping = new utils.Mapping() + mapping.internalPort = intPort + mapping.protocol = 'pcp' + + // If lifetime is zero, we want to refresh every 24 hours + const reqLifetime = (lifetime === 0) ? 24 * 60 * 60 : lifetime + + // Send PCP requests to a list of router IPs and parse the first response + function _sendPcpRequests (routerIps) { + // Construct an array of ArrayBuffers, which are the responses of + // sendPcpRequest() calls on all the router IPs. An error result + // is caught and re-passed as null. + const privateIps = Promise.all(routerIps.map((routerIp) => { + // Choose a privateIp based on the currently selected routerIp, + // using a longest prefix match, and send a PCP request with that IP + const privateIp = utils.longestPrefixMatch(privateIps, routerIp) + return this.sendPcpRequest(routerIp, privateIp, intPort, extPort, + reqLifetime) + .then(function (pcpResponse) { + return { + 'pcpResponse': pcpResponse, + 'privateIp': privateIp + } + }) + })) + + utils.getPrivateIps().then(function (privateIps) { + // Construct an array of ArrayBuffers, which are the responses of + // sendPcpRequest() calls on all the router IPs. An error result + // is caught and re-passed as null. + return Promise.all(routerIps.map(function (routerIp) { + // Choose a privateIp based on the currently selected routerIp, + // using a longest prefix match, and send a PCP request with that IP + const privateIp = utils.longestPrefixMatch(privateIps, routerIp) + return this.sendPcpRequest(routerIp, privateIp, intPort, extPort, + reqLifetime) + .then(function (pcpResponse) { + return { + 'pcpResponse': pcpResponse, + 'privateIp': privateIp + } + }) + .catch(function (err) { + return null + }) + })) + }).then(function (responses) { + // Check if any of the responses are successful (not null), and return + // it as a Mapping object + for (let i = 0; i < responses.length; i++) { + if (responses[i] !== null) { + const responseView = new DataView(responses[i].pcpResponse) + const ipOctets = [responseView.getUint8(56), responseView.getUint8(57), + responseView.getUint8(58), responseView.getUint8(59) + ] + const extIp = ipOctets.join('.') + mapping.externalPort = responseView.getUint16(42) + mapping.externalIp = extIp + mapping.internalIp = responses[i].privateIp + mapping.lifetime = responseView.getUint32(4) + mapping.nonce = [responseView.getUint32(24), + responseView.getUint32(28), + responseView.getUint32(32) + ] + if (routerIpCache.indexOf(routerIps[i]) === -1) { + routerIpCache.push(routerIps[i]) + } + } + } + return mapping + }).catch(function (err) { + return mapping + }) + } + // Basically calls _sendPcpRequests on matchedRouterIps first, and if that + // doesn't work, calls it on otherRouterIps + function _sendPcpRequestsInWaves () { + return utils.getPrivateIps().then(function (privateIps) { + // Try matchedRouterIps first (routerIpCache + router IPs that match the + // user's IPs), then otherRouterIps if it doesn't work. This avoids flooding + // the local network with PCP requests + const matchedRouterIps = utils.arrAdd(routerIpCache, utils.filterRouterIps(privateIps)) + const otherRouterIps = utils.arrDiff(utils.ROUTER_IPS, matchedRouterIps) + return _sendPcpRequests(matchedRouterIps).then(function (mapping) { + if (mapping.externalPort !== -1) { + return mapping + } + return _sendPcpRequests(otherRouterIps) + }) + }) + } + // Compare our requested parameters for the mapping with the response, + // setting a refresh if necessary, and a timeout for deletion, and saving the + // mapping object to activeMappings if the mapping succeeded + function _saveAndRefreshMapping (mapping) { + // If the actual lifetime is less than the requested lifetime, + // setTimeout to refresh the mapping when it expires + const dLifetime = reqLifetime - mapping.lifetime + if (mapping.externalPort !== -1 && dLifetime > 0) { + mapping.timeoutId = setTimeout(this.addMapping.bind(this, intPort, + mapping.externalPort, dLifetime, activeMappings), mapping.lifetime * 1000) + } else if (mapping.externalPort !== -1 && lifetime === 0) { + // If the original lifetime is 0, refresh every 24 hrs indefinitely + mapping.timeoutId = setTimeout(this.addMapping.bind(this, intPort, + mapping.externalPort, 0, activeMappings), 24 * 60 * 60 * 1000) + } else if (mapping.externalPort !== -1) { + // If we're not refreshing, delete the entry in activeMapping at expiration + setTimeout(function () { + delete activeMappings[mapping.externalPort] + }, + mapping.lifetime * 1000) + } + // If mapping succeeded, attach a deleter function and add to activeMappings + if (mapping.externalPort !== -1) { + mapping.deleter = deleteMapping.bind({}, mapping.externalPort, + activeMappings, routerIpCache) + activeMappings[mapping.externalPort] = mapping + } + return mapping + } + // Try PCP requests to matchedRouterIps, then otherRouterIps. + // After receiving a PCP response, set timeouts to delete/refresh the + // mapping, add it to activeMappings, and return the mapping object + return _sendPcpRequestsInWaves().then(_saveAndRefreshMapping) + } + + /** + * Deletes a port mapping in the NAT with PCP + * + * @param {number} extPort The external port of the mapping to delete + * @param {object} activeMappings Table of active Mappings + * @param {Array} routerIpCache Router IPs that have previously worked + * @return {Promise} True on success, false on failure + */ + deleteMapping (extPort, activeMappings, routerIpCache) { + // Send PCP requests to a list of router IPs and parse the first response + function _sendDeletionRequests (routerIps) { + return utils.getPrivateIps().then(function (privateIps) { + // Get the internal port and nonce for this mapping; this may error + const intPort = activeMappings[extPort].internalPort + const nonce = activeMappings[extPort].nonce + // Construct an array of ArrayBuffers, which are the responses of + // sendPmpRequest() calls on all the router IPs. An error result + // is caught and re-passed as null. + return Promise.all(routerIps.map(function (routerIp) { + // Choose a privateIp based on the currently selected routerIp, + // using a longest prefix match, and send a PCP request with that IP + const privateIp = utils.longestPrefixMatch(privateIps, routerIp) + return sendPcpRequest(routerIp, privateIp, intPort, 0, 0, nonce) + .then(function (pcpResponse) { + return pcpResponse + }) + .catch(function (err) { + return null + }) + })) + }) + } + // Basically calls _sendDeletionRequests on matchedRouterIps first, and if that + // doesn't work, calls it on otherRouterIps + function _sendDeletionRequestsInWaves () { + return utils.getPrivateIps().then(function (privateIps) { + // Try matchedRouterIps first (routerIpCache + router IPs that match the + // user's IPs), then otherRouterIps if it doesn't work. This avoids flooding + // the local network with PCP requests + const matchedRouterIps = utils.arrAdd(routerIpCache, utils.filterRouterIps(privateIps)) + const otherRouterIps = utils.arrDiff(utils.ROUTER_IPS, matchedRouterIps) + return _sendDeletionRequests(matchedRouterIps).then(function (mapping) { + if (mapping.externalPort !== -1) { + return mapping + } + return _sendDeletionRequests(otherRouterIps) + }) + }) + } + // If any of the PCP responses were successful, delete the entry from + // activeMappings and return true + function _deleteFromActiveMappings (responses) { + for (let i = 0; i < responses.length; i++) { + if (responses[i] !== null) { + // Success code 8 (NO_RESOURCES) may denote that the mapping does not + // exist on the router, so we accept it as well + const responseView = new DataView(responses[i]) + const successCode = responseView.getUint8(3) + if (successCode === 0 || successCode === 8) { + clearTimeout(activeMappings[extPort].timeoutId) + delete activeMappings[extPort] + return true + } + } + } + return false + } + // Send PCP deletion requests to matchedRouterIps, then otherRouterIps + // if that succeeds, delete the corresponding Mapping from activeMappings + return _sendDeletionRequestsInWaves() + .then(_deleteFromActiveMappings) + .catch(function (err) { + return false + }) + } + + /** + * Send a PCP request to the router to map a port + * + * @param {string} routerIp The IP address that the router can be reached at + * @param {string} privateIp The private IP address of the user's computer + * @param {number} intPort The internal port on the computer to map to + * @param {number} extPort The external port on the router to map to + * @param {number} lifetime Seconds that the mapping will last + * @param {array} nonce (Optional) A specified nonce for the PCP request + * @return {Promise} A promise that fulfills with the PCP response + * or rejects on timeout + */ + sendPcpRequest (routerIp, + privateIp, + intPort, + extPort, + lifetime, + nonce) { + let socket + // Pre-process nonce and privateIp arguments + if (nonce === undefined) { + nonce = [utils.randInt(0, 0xffffffff), + utils.randInt(0, 0xffffffff), + utils.randInt(0, 0xffffffff) + ] + } + const ipOctets = ipaddr.IPv4.parse(privateIp).octets + // Bind a socket and send the PCP request from that socket to routerIp + const _sendPcpRequest = new Promise(function (resolve, reject) { + socket = dgram.createSocket('udp4') + // Fulfill when we get any reply (failure is on timeout in wrapper function) + socket.on('onData', function (pcpResponse) { + utils.closeSocket(socket) + F(pcpResponse.data) + }) + // Bind a UDP port and send a PCP request + socket.bind('0.0.0.0', 0, err => { + if (err) return + // PCP packet structure: https://tools.ietf.org/html/rfc6887#section-11.1 + const pcpBuffer = utils.createArrayBuffer(60, [ + [32, 0, 0x2010000], + [32, 4, lifetime], + [16, 18, 0xffff], + [8, 20, ipOctets[0]], + [8, 21, ipOctets[1]], + [8, 22, ipOctets[2]], + [8, 23, ipOctets[3]], + [32, 24, nonce[0]], + [32, 28, nonce[1]], + [32, 32, nonce[2]], + [8, 36, 17], + [16, 40, intPort], + [16, 42, extPort], + [16, 54, 0xffff] + ]) + socket.send(pcpBuffer, 5351, routerIp) + }) + }) + // Give _sendPcpRequest 2 seconds before timing out + return Promise.race([ + utils.countdownReject(2000, 'No PCP response', function () { + utils.closeSocket(socket) + }), + _sendPcpRequest + ]) + } +} + +module.exports = PCP diff --git a/src/mappers/upnp.js b/src/mappers/upnp.js new file mode 100644 index 0000000..b370b10 --- /dev/null +++ b/src/mappers/upnp.js @@ -0,0 +1,356 @@ +'use strict' + +const utils = require('./utils') +const dgram = require('dgram') +const URL = require('url') +const request = require('superagent') + +const UPNP_PROBE_PORT = 55557 + +/** + * Probe if UPnP AddPortMapping is supported by the router + * + * @param {object} activeMappings Table of active Mappings + * @param {Array} routerIpCache Router IPs that have previously worked + * @return {Promise} A promise for a boolean + */ +const probeSupport = function (activeMappings) { + return addMapping(utils.UPNP_PROBE_PORT, utils.UPNP_PROBE_PORT, 120, + activeMappings).then(function (mapping) { + if (mapping.errInfo && + mapping.errInfo.indexOf('ConflictInMappingEntry') !== -1) { + // This error response suggests that UPnP is enabled + return true + } + return mapping.externalPort !== -1 + }) +} + +/** + * Makes a port mapping in the NAT with UPnP AddPortMapping + * + * @param {number} intPort The internal port on the computer to map to + * @param {number} extPort The external port on the router to map to + * @param {number} lifetime Seconds that the mapping will last + * 0 is infinity; a static AddPortMapping request + * @param {object} activeMappings Table of active Mappings + * @param {string=} controlUrl Optional: a control URL for the router + * @return {Promise} A promise for the port mapping object + * mapping.externalPort is -1 on failure + */ +const addMapping = function (intPort, + extPort, + lifetime, + activeMappings, + controlUrl) { + let internalIp // Internal IP of the user's computer + const mapping = new utils.Mapping() + mapping.internalPort = intPort + mapping.protocol = 'upnp' + // Does the UPnP flow to send a AddPortMapping request + // (1. SSDP, 2. GET location URL, 3. POST to control URL) + // If we pass in a control URL, we don't need to do the SSDP step + function _handleUpnpFlow () { + if (controlUrl !== undefined) { + return _handleControlUrl(controlUrl) + } + return _getUpnpControlUrl().then(function (url) { + controlUrl = url + return _handleControlUrl(url) + }).catch(_handleError) + } + // Process and send an AddPortMapping request to the control URL + function _handleControlUrl (controlUrl) { + return new Promise(function (resolve, reject) { + // Get the correct internal IP (if there are multiple network interfaces) + // for this UPnP router, by doing a longest prefix match, and use it to + // send an AddPortMapping request + const routerIp = (new URL(controlUrl)).hostname + utils.getPrivateIps().then(function (privateIps) { + internalIp = utils.longestPrefixMatch(privateIps, routerIp) + sendAddPortMapping(controlUrl, internalIp, intPort, extPort, lifetime) + .then(function (response) { + resolve(response) + }) + .catch(function (err) { + resolve(err) + }) + }) + }).then(function (response) { + // Success response to AddPortMapping (the internal IP of the mapping) + // The requested external port will always be mapped on success, and the + // lifetime will always be the requested lifetime; errors otherwise + mapping.externalPort = extPort + mapping.internalIp = internalIp + mapping.lifetime = lifetime + return mapping + }).catch(_handleError) + } + // Save the Mapping object in activeMappings on success, and set a timeout + // to delete the mapping on expiration + // Note: We never refresh for UPnP since 0 is infinity per the protocol and + // there is no maximum lifetime + function _saveMapping (mapping) { + // Delete the entry from activeMapping at expiration + if (mapping.externalPort !== -1 && lifetime !== 0) { + setTimeout(function () { + delete activeMappings[mapping.externalPort] + }, mapping.lifetime * 1000) + } + // If mapping succeeded, attach a deleter function and add to activeMappings + if (mapping.externalPort !== -1) { + mapping.deleter = deleteMapping.bind({}, mapping.externalPort, + activeMappings, controlUrl) + activeMappings[mapping.externalPort] = mapping + } + return mapping + } + // If we catch an error, add it to the mapping object and console.log() + function _handleError (err) { + // console.log('UPnP failed at: ' + err.message) + mapping.errInfo = err.message + return mapping + } + // After receiving an AddPortMapping response, set a timeout to delete the + // mapping, and add it to activeMappings + return _handleUpnpFlow().then(_saveMapping) +} + +/** + * Deletes a port mapping in the NAT with UPnP DeletePortMapping + * + * @param {number} extPort The external port of the mapping to delete + * @param {object} activeMappings Table of active Mappings + * @param {string} controlUrl A control URL for the router (not optional!) + * @return {Promise} True on success, false on failure + */ +const deleteMapping = function (extPort, activeMappings, controlUrl) { + // Do the UPnP flow to delete a mapping, and if successful, remove it from + // activeMappings and return true + return sendDeletePortMapping(controlUrl, extPort).then(function () { + delete activeMappings[extPort] + return true + }).catch(function (err) { + return false + }) +} + +/** + * Return the UPnP control URL of a router on the network that supports UPnP IGD + * This wraps sendSsdpRequest() and fetchControlUrl() together + * + * @return {Promise} A promise for the URL, rejects if not supported + */ +const _getUpnpControlUrl = function () { + // After collecting all the SSDP responses, try to get the + // control URL field for each response, and return an array + return sendSsdpRequest() + .then(function (ssdpResponses) { + return Promise.all(ssdpResponses.map(function (ssdpResponse) { + return fetchControlUrl(ssdpResponse) + .then(function (controlUrl) { + return controlUrl + }) + .catch(function (err) { + return null + }) + })) + }).then(function (controlUrls) { + // We return the first control URL we found + // there should always be at least one if we reached this block + for (let i = 0; i < controlUrls.length; i++) { + if (controlUrls[i] !== null) { + return controlUrls[i] + } + } + }).catch(function (err) { + return Promise.reject(err) + }) +} + +/** + * A public version of _getUpnpControlUrl that suppresses the Promise rejection, + * and replaces it with undefined. This is useful outside this module in a + * Promise.all(), while inside we want to propagate the errors upwards + * + * @return {Promise} A promise for the URL, undefined if not supported + */ +const getUpnpControlUrl = function () { + return _getUpnpControlUrl().catch(function (err) {}) +} + +/** + * Send a UPnP SSDP request on the network and collects responses + * + * @return {Promise} A promise that fulfills with an array of SSDP response, + * or rejects on timeout + */ +const sendSsdpRequest = function () { + const ssdpResponses = [] + const socket = dgram.createSocket('udp4') + // Fulfill when we get any reply (failure is on timeout or invalid parsing) + socket.on('onData', function (ssdpResponse) { + ssdpResponses.push(ssdpResponse.data) + }) + // Bind a socket and send the SSDP request + socket.bind('0.0.0.0', 0, err => { + if (err) return + // Construct and send a UPnP SSDP message + const ssdpStr = 'M-SEARCH * HTTP/1.1\r\n' + + 'HOST: 239.255.255.250:1900\r\n' + + 'MAN: "ssdp:discover"\r\n' + + 'MX: 3\r\n' + + 'ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n' + const ssdpBuffer = utils.stringToArrayBuffer(ssdpStr) + socket.send(ssdpBuffer, 1900, '239.255.255.250') + }) + // Collect SSDP responses for 3 seconds before timing out + return new Promise(function (resolve, reject) { + setTimeout(function () { + if (ssdpResponses.length > 0) { + resolve(ssdpResponses) + } else { + resolve(new Error('SSDP timeout')) + } + }, 3000) + }) +} + +/** + * Fetch the control URL from the information provided in the SSDP response + * + * @param {ArrayBuffer} ssdpResponse The ArrayBuffer response to the SSDP message + * @return {string} The string of the control URL for the router + */ +const fetchControlUrl = function (ssdpResponse) { + // Promise to parse the location URL from the SSDP response, then send a POST + // xhr to the location URL to find the router's UPNP control URL + const _fetchControlUrl = new Promise(function (resolve, reject) { + const ssdpStr = utils.arrayBufferToString(ssdpResponse) + const startIndex = ssdpStr.indexOf('LOCATION:') + 9 + const endIndex = ssdpStr.indexOf('\n', startIndex) + const locationUrl = ssdpStr.substring(startIndex, endIndex).trim() + // Reject if there is no LOCATION header + if (startIndex === 8) { + resolve(new Error('No LOCATION header for UPnP device')) + return + } + + // Get the XML device description at location URL + request + .get(locationUrl) + .type('xml') + .end((err, res) => { + if (err) { + return reject(err) + } + + if (!res.body.controlUrl) { + resolve(new Error('Could not parse control URL')) + return + } + + // Combine the controlUrl path with the locationUrl + const lcUrl = new URL(locationUrl).host + resolve(`http://${lcUrl}/${res.body.controlUrl}`) + }) + }) + // Give _fetchControlUrl 1 second before timing out + return Promise.race([ + utils.countdownReject(1000, 'Time out when retrieving description XML'), + _fetchControlUrl + ]) +} + +/** + * Send an AddPortMapping request to the router's control URL + * + * @param {string} controlUrl The control URL of the router + * @param {string} privateIp The private IP address of the user's computer + * @param {number} intPort The internal port on the computer to map to + * @param {number} extPort The external port on the router to map to + * @param {number} lifetime Seconds that the mapping will last + * @return {string} The response string to the AddPortMapping request + */ +const sendAddPortMapping = function (controlUrl, privateIp, intPort, extPort, lifetime) { + // Promise to send an AddPortMapping request to the control URL of the router + const _sendAddPortMapping = new Promise(function (resolve, reject) { + // The AddPortMapping SOAP request string + const apm = '' + + '' + + '' + + '' + + '' + extPort + '' + + 'UDP' + + '' + intPort + '' + + '' + privateIp + '' + + '1' + + 'uProxy UPnP' + + '' + lifetime + '' + + '' + + '' + + '' + // Create an XMLHttpRequest that encapsulates the SOAP string + request.post(controlUrl) + .type('xml') + .set('Content-Type', 'text/xml') + .set('SOAPAction', '"urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping"') + .send(apm, (err, res) => { + if (err) { return reject(err) } + resolve(res.body) + }) + + // Give _sendAddPortMapping 1 second to run before timing out + return Promise.race([ + utils.countdownReject(1000, 'AddPortMapping time out'), + _sendAddPortMapping + ]) + }) +} + +/** + * Send a DeletePortMapping request to the router's control URL + * + * @param {string} controlUrl The control URL of the router + * @param {number} extPort The external port of the mapping to delete + * @return {string} The response string to the AddPortMapping request + */ +const sendDeletePortMapping = function (controlUrl, extPort) { + // Promise to send an AddPortMapping request to the control URL of the router + const _sendDeletePortMapping = new Promise(function (resolve, reject) { + // The DeletePortMapping SOAP request string + const apm = '' + + '' + + '' + + '' + + '' + + '' + extPort + '' + + 'UDP' + + '' + + '' + + '' + // Create an XMLHttpRequest that encapsulates the SOAP string + request.post(controlUrl) + .type('xml') + .set('Content-Type', 'text/xml') + .set('SOAPAction', '"urn:schemas-upnp-org:service:WANIPConnection:1#DeletePortMapping"') + .send(apm, (err, res) => { + if (err) { + return reject(err) + } + resolve(res.body) + }) + }) + + // Give _sendDeletePortMapping 1 second to run before timing out + return Promise.race([ + utils.countdownReject(1000, 'DeletePortMapping time out'), + _sendDeletePortMapping + ]) +} +module.exports = { + probeSupport: probeSupport, + addMapping: addMapping, + deleteMapping: deleteMapping, + getUpnpControlUrl: getUpnpControlUrl +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..5f4ef64 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,223 @@ +'use strict' +const ipaddr = require('ipaddr.js') +const os = require('os') + +/** + * List of popular router default IPs + * Used as destination addresses for NAT-PMP and PCP requests + * http://www.techspot.com/guides/287-default-router-ip-addresses/ + */ +const ROUTER_IPS = ['192.168.1.1', '192.168.2.1', '192.168.11.1', + '192.168.0.1', '192.168.0.30', '192.168.0.50', '192.168.20.1', + '192.168.30.1', '192.168.62.1', '192.168.100.1', '192.168.102.1', + '192.168.1.254', '192.168.10.1', '192.168.123.254', '192.168.4.1', + '10.0.0.1', '10.0.1.1', '10.1.1.1', '10.0.0.13', '10.0.0.2', + '10.0.0.138' +] + +/** + * Return the private IP addresses of the computer + */ +function getPrivateIps () { + const ifs = os.networkInterfaces() + return Object.keys(ifs) + .map(k => ifs[k]) + .reduce((a, b) => a.concat(b), []) + .filter(i => !i.internal) + .map(i => i.address) + .filter(a => ipaddr.IPv4.isValid(a)) +} + +/** +* Filters routerIps for only those that match any of the user's IPs in privateIps +* i.e. The longest prefix matches of the router IPs with each user IP* +* +* @param {Array} privateIps Private IPs to match router IPs to +* @return {Array} Router IPs that matched (one per private IP) +*/ +function filterRouterIps (privateIps) { + let routerIps = [] + privateIps.forEach(function (privateIp) { + routerIps.push(longestPrefixMatch(ROUTER_IPS, privateIp)) + }) + return routerIps +} + +/** + * Creates an ArrayBuffer with a compact matrix notation, i.e. + * [[bits, byteOffset, value], + * [8, 0, 1], //=> DataView.setInt8(0, 1) + * ... ] + * + * @param {number} bytes Size of the ArrayBuffer in bytes + * @param {Array>} matrix Matrix of values for the ArrayBuffer + * @return {ArrayBuffer} An ArrayBuffer constructed from matrix + */ +const createArrayBuffer = function (bytes, matrix) { + const buffer = new ArrayBuffer(bytes) + const view = new DataView(buffer) + for (let i = 0; i < matrix.length; i++) { + const row = matrix[i] + if (row[0] === 8) { + view.setInt8(row[1], row[2]) + } else if (row[0] === 16) { + view.setInt16(row[1], row[2], false) + } else if (row[0] === 32) { + view.setInt32(row[1], row[2], false) + } else { + console.error('Invalid parameters to createArrayBuffer') + } + } + return Buffer.from(buffer) +} + +/** + * Return a promise that rejects in a given time with an Error message, + * and can call a callback function before rejecting + * + * @param {number} time Time in seconds + * @param {number} msg Message to encapsulate in the rejected Error + * @param {function} callback Function to call before rejecting + * @return {Promise} A promise that will reject in the given time + */ +const countdownReject = function (time, msg, callback) { + return new Promise(function (resolve, reject) { + setTimeout(function () { + if (callback !== undefined) { + callback() + } + reject(new Error(msg)) + }, time) + }) +} + +/** + * Close the OS-level sockets and discard its Freedom object + * + * @param {freedom_UdpSocket.Socket} socket The socket object to close + */ +const closeSocket = function (socket) { + socket.close() +} + +/** + * Takes a list of IP addresses and an IP address, and returns the longest prefix + * match in the IP list with the IP + * + * @param {Array} ipList List of IP addresses to find the longest prefix match in + * @param {string} matchIp The router's IP address as a string + * @return {string} The IP from the given list with the longest prefix match + */ +const longestPrefixMatch = function (ipList, matchIp) { + const prefixMatches = [] + matchIp = ipaddr.IPv4.parse(matchIp) + for (let i = 0; i < ipList.length; i++) { + const ip = ipaddr.IPv4.parse(ipList[i]) + // Use ipaddr.js to find the longest prefix length (mask length) + for (let mask = 1; mask < 32; mask++) { + if (!ip.match(matchIp, mask)) { + prefixMatches.push(mask - 1) + break + } + } + } + // Find the argmax for prefixMatches, i.e. the index of the correct private IP + const maxIndex = prefixMatches.indexOf(Math.max.apply(null, prefixMatches)) + const correctIp = ipList[maxIndex] + return correctIp +} + +/** + * Return a random integer in a specified range + * + * @param {number} min Lower bound for the random integer + * @param {number} max Upper bound for the random integer + * @return {number} A random number between min and max + */ +const randInt = function (min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +/** + * Convert an ArrayBuffer to a UTF-8 string + * @public + * @method arrayBufferToString + * @param {ArrayBuffer} buffer ArrayBuffer to convert + * @return {string} A string converted from the ArrayBuffer + */ +const arrayBufferToString = function (buffer) { + const bytes = new Uint8Array(buffer) + const a = [] + for (let i = 0; i < bytes.length; ++i) { + a.push(String.fromCharCode(bytes[i])) + } + return a.join('') +} + +/** + * Convert a UTF-8 string to an ArrayBuffer + * + * @param {string} s String to convert + * @return {ArrayBuffer} An ArrayBuffer containing the string data + */ +const stringToArrayBuffer = function (s) { + const buffer = new ArrayBuffer(s.length) + const bytes = new Uint8Array(buffer) + for (let i = 0; i < s.length; ++i) { + bytes[i] = s.charCodeAt(i) + } + return Buffer.from(buffer) +} + +/** + * Returns the difference between two arrays + * + * @param {Array} listA + * @param {Array} listB + * @return {Array} The difference array + */ +const arrDiff = function (listA, listB) { + const diff = [] + listA.forEach(function (a) { + if (listB.indexOf(a) === -1) { + diff.push(a) + } + }) + return diff +} + +/** + * Adds two arrays, but doesn't include repeated elements + * + * @param {Array} listA + * @param {Array} listB + * @return {Array} The sum of the two arrays with no duplicates + */ +const arrAdd = function (listA, listB) { + const sum = [] + listA.forEach(function (a) { + if (sum.indexOf(a) === -1) { + sum.push(a) + } + }) + listB.forEach(function (b) { + if (sum.indexOf(b) === -1) { + sum.push(b) + } + }) + return sum +} +module.exports = { + ROUTER_IPS, + createArrayBuffer, + countdownReject, + closeSocket, + longestPrefixMatch, + randInt, + arrayBufferToString, + stringToArrayBuffer, + arrAdd, + arrDiff, + getPrivateIps, + filterRouterIps +} From 88f611f336db3d16780705c26bffaf22969e8edc Mon Sep 17 00:00:00 2001 From: Dmitriy Ryajov Date: Fri, 25 May 2018 21:18:30 -0600 Subject: [PATCH 02/11] feat: add upnp protocol --- package.json | 17 +- src/index.js | 9 +- src/mappers/nat-pmp.js | 72 -------- src/mappers/pcp.js | 310 ------------------------------- src/mappers/upnp.js | 407 +++++++---------------------------------- 5 files changed, 84 insertions(+), 731 deletions(-) delete mode 100644 src/mappers/nat-pmp.js delete mode 100644 src/mappers/pcp.js diff --git a/package.json b/package.json index 431b7f1..18e1d84 100644 --- a/package.json +++ b/package.json @@ -2,17 +2,26 @@ "name": "js-libp2p-nat-mgr", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "src/index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "lint": "aegir lint", + "docs": "aegir docs", + "build": "aegir build", + "test": "aegir test -t node", + "test:node": "aegir test -t node", + "release": "aegir release", + "release-minor": "aegir release --type minor", + "release-major": "aegir release --type major", + "coverage": "COVERAGE=true aegir coverage --timeout 50000", + "coverage-publish": "aegir coverage -u" }, "author": "", "license": "ISC", "dependencies": { - "": "^2.6.1", "dgram": "^1.0.1", "ipaddr.js": "^1.7.0", - "nat-pmp": "^1.0.0" + "nat-pmp": "^1.0.0", + "nat-upnp": "^1.1.1" }, "devDependencies": { "aegir": "^13.1.0", diff --git a/src/index.js b/src/index.js index 8da60bf..24eb8eb 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,9 @@ 'use strict' const utils = require('./utils') -const NatPmp = require('./mappers/nat-pmp') +const NatPmp = require('./mappers/pmp') // const PCP = require('./pcp') -// const UPnP = require('./upnp') +const UPnP = require('./mappers/upnp') const EE = require('events') const tryEach = require('async/tryEach') @@ -12,7 +12,8 @@ class NatManager extends EE { super() this.mappers = [ - new NatPmp() + new NatPmp(), + new UPnP() ] this.activeMappings = {} @@ -45,7 +46,7 @@ class NatManager extends EE { } getRouterIpCache () { - return this.routerIpCache + return [...this.routerIpCache] } getPrivateIps () { diff --git a/src/mappers/nat-pmp.js b/src/mappers/nat-pmp.js deleted file mode 100644 index 8345b24..0000000 --- a/src/mappers/nat-pmp.js +++ /dev/null @@ -1,72 +0,0 @@ -'use strict' - -const natPmp = require('nat-pmp') -const waterfall = require('async/waterfall') - -const Mapper = require('./mapper') -const utils = require('../utils') - -const NAT_PMP_PROBE_PORT = 55555 - -class NatPMP extends Mapper { - constructor () { - super('natPmp', NAT_PMP_PROBE_PORT) - } - - /** - * Create a port mapping - * - * @param {String} routerIp - * @param {Number} intPort - * @param {Number} extPort - * @param {Number} ttl - * @param {Function} callback - */ - createMapping (routerIp, intPort, extPort, ttl, callback) { - const client = natPmp.connect(routerIp) - waterfall([ - (cb) => client.externalIp((err, info) => { - if (err) { - return callback(err) - } - const mapping = this.newMapping(intPort) - mapping.externalIp = info.ip.join('.') - cb(null, mapping) - }), - (mapping, cb) => { - client.portMapping({ private: intPort, public: extPort, ttl }, (err, info) => { - if (err) { - this.log.err(err) - return cb(err) - } - - mapping.externalPort = info.public - mapping.internalPort = info.private - mapping.ttl = info.ttl - // get the internal ip of the interface - // we're using to make the request - const internalIp = utils.longestPrefixMatch(utils.getPrivateIps(), routerIp) - mapping.internalIp = internalIp - cb(null, mapping) - }) - } - ], (err, mapping) => { - if (err) { - return callback(err) - } - callback(null, mapping) - }) - } - - deleteMapping (intPort, routerIp, extPort, callback) { - const client = natPmp.connect(routerIp) - client.portUnmapping({private: intPort, public: extPort}, (err, info) => { - if (err) { - return callback(err) - } - return callback(null, err) - }) - } -} - -module.exports = NatPMP diff --git a/src/mappers/pcp.js b/src/mappers/pcp.js deleted file mode 100644 index 12865d8..0000000 --- a/src/mappers/pcp.js +++ /dev/null @@ -1,310 +0,0 @@ -'use strict' - -const utils = require('./utils') -const ipaddr = require('ipaddr.js') -const dgram = require('dgram') - -const PCP_PROBE_PORT = 55556 - -class PCP { - /** - * Probe if PCP is supported by the router - * - * @param {object} activeMappings Table of active Mappings - * @param {Array} routerIpCache Router IPs that have previously worked - * @return {Promise} A promise for a boolean - */ - probeSupport (activeMappings, routerIpCache) { - const mapping = this.addMapping(utils.PCP_PROBE_PORT, - utils.PCP_PROBE_PORT, - 120, - activeMappings, - routerIpCache) - return mapping.externalPort !== -1 - } - - /** - * Makes a port mapping in the NAT with PCP, - * and automatically refresh the mapping every two minutes - * - * @param {number} intPort The internal port on the computer to map to - * @param {number} extPort The external port on the router to map to - * @param {number} lifetime Seconds that the mapping will last - * 0 is infinity, i.e. a refresh every 24 hours - * @param {object} activeMappings Table of active Mappings - * @param {Array} routerIpCache Router IPs that have previously worked - * @return {Promise} A promise for the port mapping object - * mapping.externalPort is -1 on failure - */ - addMapping (intPort, extPort, lifetime, activeMappings, routerIpCache) { - const mapping = new utils.Mapping() - mapping.internalPort = intPort - mapping.protocol = 'pcp' - - // If lifetime is zero, we want to refresh every 24 hours - const reqLifetime = (lifetime === 0) ? 24 * 60 * 60 : lifetime - - // Send PCP requests to a list of router IPs and parse the first response - function _sendPcpRequests (routerIps) { - // Construct an array of ArrayBuffers, which are the responses of - // sendPcpRequest() calls on all the router IPs. An error result - // is caught and re-passed as null. - const privateIps = Promise.all(routerIps.map((routerIp) => { - // Choose a privateIp based on the currently selected routerIp, - // using a longest prefix match, and send a PCP request with that IP - const privateIp = utils.longestPrefixMatch(privateIps, routerIp) - return this.sendPcpRequest(routerIp, privateIp, intPort, extPort, - reqLifetime) - .then(function (pcpResponse) { - return { - 'pcpResponse': pcpResponse, - 'privateIp': privateIp - } - }) - })) - - utils.getPrivateIps().then(function (privateIps) { - // Construct an array of ArrayBuffers, which are the responses of - // sendPcpRequest() calls on all the router IPs. An error result - // is caught and re-passed as null. - return Promise.all(routerIps.map(function (routerIp) { - // Choose a privateIp based on the currently selected routerIp, - // using a longest prefix match, and send a PCP request with that IP - const privateIp = utils.longestPrefixMatch(privateIps, routerIp) - return this.sendPcpRequest(routerIp, privateIp, intPort, extPort, - reqLifetime) - .then(function (pcpResponse) { - return { - 'pcpResponse': pcpResponse, - 'privateIp': privateIp - } - }) - .catch(function (err) { - return null - }) - })) - }).then(function (responses) { - // Check if any of the responses are successful (not null), and return - // it as a Mapping object - for (let i = 0; i < responses.length; i++) { - if (responses[i] !== null) { - const responseView = new DataView(responses[i].pcpResponse) - const ipOctets = [responseView.getUint8(56), responseView.getUint8(57), - responseView.getUint8(58), responseView.getUint8(59) - ] - const extIp = ipOctets.join('.') - mapping.externalPort = responseView.getUint16(42) - mapping.externalIp = extIp - mapping.internalIp = responses[i].privateIp - mapping.lifetime = responseView.getUint32(4) - mapping.nonce = [responseView.getUint32(24), - responseView.getUint32(28), - responseView.getUint32(32) - ] - if (routerIpCache.indexOf(routerIps[i]) === -1) { - routerIpCache.push(routerIps[i]) - } - } - } - return mapping - }).catch(function (err) { - return mapping - }) - } - // Basically calls _sendPcpRequests on matchedRouterIps first, and if that - // doesn't work, calls it on otherRouterIps - function _sendPcpRequestsInWaves () { - return utils.getPrivateIps().then(function (privateIps) { - // Try matchedRouterIps first (routerIpCache + router IPs that match the - // user's IPs), then otherRouterIps if it doesn't work. This avoids flooding - // the local network with PCP requests - const matchedRouterIps = utils.arrAdd(routerIpCache, utils.filterRouterIps(privateIps)) - const otherRouterIps = utils.arrDiff(utils.ROUTER_IPS, matchedRouterIps) - return _sendPcpRequests(matchedRouterIps).then(function (mapping) { - if (mapping.externalPort !== -1) { - return mapping - } - return _sendPcpRequests(otherRouterIps) - }) - }) - } - // Compare our requested parameters for the mapping with the response, - // setting a refresh if necessary, and a timeout for deletion, and saving the - // mapping object to activeMappings if the mapping succeeded - function _saveAndRefreshMapping (mapping) { - // If the actual lifetime is less than the requested lifetime, - // setTimeout to refresh the mapping when it expires - const dLifetime = reqLifetime - mapping.lifetime - if (mapping.externalPort !== -1 && dLifetime > 0) { - mapping.timeoutId = setTimeout(this.addMapping.bind(this, intPort, - mapping.externalPort, dLifetime, activeMappings), mapping.lifetime * 1000) - } else if (mapping.externalPort !== -1 && lifetime === 0) { - // If the original lifetime is 0, refresh every 24 hrs indefinitely - mapping.timeoutId = setTimeout(this.addMapping.bind(this, intPort, - mapping.externalPort, 0, activeMappings), 24 * 60 * 60 * 1000) - } else if (mapping.externalPort !== -1) { - // If we're not refreshing, delete the entry in activeMapping at expiration - setTimeout(function () { - delete activeMappings[mapping.externalPort] - }, - mapping.lifetime * 1000) - } - // If mapping succeeded, attach a deleter function and add to activeMappings - if (mapping.externalPort !== -1) { - mapping.deleter = deleteMapping.bind({}, mapping.externalPort, - activeMappings, routerIpCache) - activeMappings[mapping.externalPort] = mapping - } - return mapping - } - // Try PCP requests to matchedRouterIps, then otherRouterIps. - // After receiving a PCP response, set timeouts to delete/refresh the - // mapping, add it to activeMappings, and return the mapping object - return _sendPcpRequestsInWaves().then(_saveAndRefreshMapping) - } - - /** - * Deletes a port mapping in the NAT with PCP - * - * @param {number} extPort The external port of the mapping to delete - * @param {object} activeMappings Table of active Mappings - * @param {Array} routerIpCache Router IPs that have previously worked - * @return {Promise} True on success, false on failure - */ - deleteMapping (extPort, activeMappings, routerIpCache) { - // Send PCP requests to a list of router IPs and parse the first response - function _sendDeletionRequests (routerIps) { - return utils.getPrivateIps().then(function (privateIps) { - // Get the internal port and nonce for this mapping; this may error - const intPort = activeMappings[extPort].internalPort - const nonce = activeMappings[extPort].nonce - // Construct an array of ArrayBuffers, which are the responses of - // sendPmpRequest() calls on all the router IPs. An error result - // is caught and re-passed as null. - return Promise.all(routerIps.map(function (routerIp) { - // Choose a privateIp based on the currently selected routerIp, - // using a longest prefix match, and send a PCP request with that IP - const privateIp = utils.longestPrefixMatch(privateIps, routerIp) - return sendPcpRequest(routerIp, privateIp, intPort, 0, 0, nonce) - .then(function (pcpResponse) { - return pcpResponse - }) - .catch(function (err) { - return null - }) - })) - }) - } - // Basically calls _sendDeletionRequests on matchedRouterIps first, and if that - // doesn't work, calls it on otherRouterIps - function _sendDeletionRequestsInWaves () { - return utils.getPrivateIps().then(function (privateIps) { - // Try matchedRouterIps first (routerIpCache + router IPs that match the - // user's IPs), then otherRouterIps if it doesn't work. This avoids flooding - // the local network with PCP requests - const matchedRouterIps = utils.arrAdd(routerIpCache, utils.filterRouterIps(privateIps)) - const otherRouterIps = utils.arrDiff(utils.ROUTER_IPS, matchedRouterIps) - return _sendDeletionRequests(matchedRouterIps).then(function (mapping) { - if (mapping.externalPort !== -1) { - return mapping - } - return _sendDeletionRequests(otherRouterIps) - }) - }) - } - // If any of the PCP responses were successful, delete the entry from - // activeMappings and return true - function _deleteFromActiveMappings (responses) { - for (let i = 0; i < responses.length; i++) { - if (responses[i] !== null) { - // Success code 8 (NO_RESOURCES) may denote that the mapping does not - // exist on the router, so we accept it as well - const responseView = new DataView(responses[i]) - const successCode = responseView.getUint8(3) - if (successCode === 0 || successCode === 8) { - clearTimeout(activeMappings[extPort].timeoutId) - delete activeMappings[extPort] - return true - } - } - } - return false - } - // Send PCP deletion requests to matchedRouterIps, then otherRouterIps - // if that succeeds, delete the corresponding Mapping from activeMappings - return _sendDeletionRequestsInWaves() - .then(_deleteFromActiveMappings) - .catch(function (err) { - return false - }) - } - - /** - * Send a PCP request to the router to map a port - * - * @param {string} routerIp The IP address that the router can be reached at - * @param {string} privateIp The private IP address of the user's computer - * @param {number} intPort The internal port on the computer to map to - * @param {number} extPort The external port on the router to map to - * @param {number} lifetime Seconds that the mapping will last - * @param {array} nonce (Optional) A specified nonce for the PCP request - * @return {Promise} A promise that fulfills with the PCP response - * or rejects on timeout - */ - sendPcpRequest (routerIp, - privateIp, - intPort, - extPort, - lifetime, - nonce) { - let socket - // Pre-process nonce and privateIp arguments - if (nonce === undefined) { - nonce = [utils.randInt(0, 0xffffffff), - utils.randInt(0, 0xffffffff), - utils.randInt(0, 0xffffffff) - ] - } - const ipOctets = ipaddr.IPv4.parse(privateIp).octets - // Bind a socket and send the PCP request from that socket to routerIp - const _sendPcpRequest = new Promise(function (resolve, reject) { - socket = dgram.createSocket('udp4') - // Fulfill when we get any reply (failure is on timeout in wrapper function) - socket.on('onData', function (pcpResponse) { - utils.closeSocket(socket) - F(pcpResponse.data) - }) - // Bind a UDP port and send a PCP request - socket.bind('0.0.0.0', 0, err => { - if (err) return - // PCP packet structure: https://tools.ietf.org/html/rfc6887#section-11.1 - const pcpBuffer = utils.createArrayBuffer(60, [ - [32, 0, 0x2010000], - [32, 4, lifetime], - [16, 18, 0xffff], - [8, 20, ipOctets[0]], - [8, 21, ipOctets[1]], - [8, 22, ipOctets[2]], - [8, 23, ipOctets[3]], - [32, 24, nonce[0]], - [32, 28, nonce[1]], - [32, 32, nonce[2]], - [8, 36, 17], - [16, 40, intPort], - [16, 42, extPort], - [16, 54, 0xffff] - ]) - socket.send(pcpBuffer, 5351, routerIp) - }) - }) - // Give _sendPcpRequest 2 seconds before timing out - return Promise.race([ - utils.countdownReject(2000, 'No PCP response', function () { - utils.closeSocket(socket) - }), - _sendPcpRequest - ]) - } -} - -module.exports = PCP diff --git a/src/mappers/upnp.js b/src/mappers/upnp.js index b370b10..cc99e34 100644 --- a/src/mappers/upnp.js +++ b/src/mappers/upnp.js @@ -1,356 +1,81 @@ 'use strict' -const utils = require('./utils') -const dgram = require('dgram') -const URL = require('url') -const request = require('superagent') +const natUpnp = require('nat-upnp') +const waterfall = require('async/waterfall') -const UPNP_PROBE_PORT = 55557 +const Mapper = require('./mapper') +const utils = require('../utils') -/** - * Probe if UPnP AddPortMapping is supported by the router - * - * @param {object} activeMappings Table of active Mappings - * @param {Array} routerIpCache Router IPs that have previously worked - * @return {Promise} A promise for a boolean - */ -const probeSupport = function (activeMappings) { - return addMapping(utils.UPNP_PROBE_PORT, utils.UPNP_PROBE_PORT, 120, - activeMappings).then(function (mapping) { - if (mapping.errInfo && - mapping.errInfo.indexOf('ConflictInMappingEntry') !== -1) { - // This error response suggests that UPnP is enabled - return true - } - return mapping.externalPort !== -1 - }) -} +const NAT_PMP_PROBE_PORT = 55555 -/** - * Makes a port mapping in the NAT with UPnP AddPortMapping - * - * @param {number} intPort The internal port on the computer to map to - * @param {number} extPort The external port on the router to map to - * @param {number} lifetime Seconds that the mapping will last - * 0 is infinity; a static AddPortMapping request - * @param {object} activeMappings Table of active Mappings - * @param {string=} controlUrl Optional: a control URL for the router - * @return {Promise} A promise for the port mapping object - * mapping.externalPort is -1 on failure - */ -const addMapping = function (intPort, - extPort, - lifetime, - activeMappings, - controlUrl) { - let internalIp // Internal IP of the user's computer - const mapping = new utils.Mapping() - mapping.internalPort = intPort - mapping.protocol = 'upnp' - // Does the UPnP flow to send a AddPortMapping request - // (1. SSDP, 2. GET location URL, 3. POST to control URL) - // If we pass in a control URL, we don't need to do the SSDP step - function _handleUpnpFlow () { - if (controlUrl !== undefined) { - return _handleControlUrl(controlUrl) - } - return _getUpnpControlUrl().then(function (url) { - controlUrl = url - return _handleControlUrl(url) - }).catch(_handleError) - } - // Process and send an AddPortMapping request to the control URL - function _handleControlUrl (controlUrl) { - return new Promise(function (resolve, reject) { - // Get the correct internal IP (if there are multiple network interfaces) - // for this UPnP router, by doing a longest prefix match, and use it to - // send an AddPortMapping request - const routerIp = (new URL(controlUrl)).hostname - utils.getPrivateIps().then(function (privateIps) { - internalIp = utils.longestPrefixMatch(privateIps, routerIp) - sendAddPortMapping(controlUrl, internalIp, intPort, extPort, lifetime) - .then(function (response) { - resolve(response) - }) - .catch(function (err) { - resolve(err) - }) - }) - }).then(function (response) { - // Success response to AddPortMapping (the internal IP of the mapping) - // The requested external port will always be mapped on success, and the - // lifetime will always be the requested lifetime; errors otherwise - mapping.externalPort = extPort - mapping.internalIp = internalIp - mapping.lifetime = lifetime - return mapping - }).catch(_handleError) - } - // Save the Mapping object in activeMappings on success, and set a timeout - // to delete the mapping on expiration - // Note: We never refresh for UPnP since 0 is infinity per the protocol and - // there is no maximum lifetime - function _saveMapping (mapping) { - // Delete the entry from activeMapping at expiration - if (mapping.externalPort !== -1 && lifetime !== 0) { - setTimeout(function () { - delete activeMappings[mapping.externalPort] - }, mapping.lifetime * 1000) - } - // If mapping succeeded, attach a deleter function and add to activeMappings - if (mapping.externalPort !== -1) { - mapping.deleter = deleteMapping.bind({}, mapping.externalPort, - activeMappings, controlUrl) - activeMappings[mapping.externalPort] = mapping - } - return mapping - } - // If we catch an error, add it to the mapping object and console.log() - function _handleError (err) { - // console.log('UPnP failed at: ' + err.message) - mapping.errInfo = err.message - return mapping +class NatPMP extends Mapper { + constructor () { + super('natPmp', NAT_PMP_PROBE_PORT) } - // After receiving an AddPortMapping response, set a timeout to delete the - // mapping, and add it to activeMappings - return _handleUpnpFlow().then(_saveMapping) -} - -/** - * Deletes a port mapping in the NAT with UPnP DeletePortMapping - * - * @param {number} extPort The external port of the mapping to delete - * @param {object} activeMappings Table of active Mappings - * @param {string} controlUrl A control URL for the router (not optional!) - * @return {Promise} True on success, false on failure - */ -const deleteMapping = function (extPort, activeMappings, controlUrl) { - // Do the UPnP flow to delete a mapping, and if successful, remove it from - // activeMappings and return true - return sendDeletePortMapping(controlUrl, extPort).then(function () { - delete activeMappings[extPort] - return true - }).catch(function (err) { - return false - }) -} -/** - * Return the UPnP control URL of a router on the network that supports UPnP IGD - * This wraps sendSsdpRequest() and fetchControlUrl() together - * - * @return {Promise} A promise for the URL, rejects if not supported - */ -const _getUpnpControlUrl = function () { - // After collecting all the SSDP responses, try to get the - // control URL field for each response, and return an array - return sendSsdpRequest() - .then(function (ssdpResponses) { - return Promise.all(ssdpResponses.map(function (ssdpResponse) { - return fetchControlUrl(ssdpResponse) - .then(function (controlUrl) { - return controlUrl - }) - .catch(function (err) { - return null - }) - })) - }).then(function (controlUrls) { - // We return the first control URL we found - // there should always be at least one if we reached this block - for (let i = 0; i < controlUrls.length; i++) { - if (controlUrls[i] !== null) { - return controlUrls[i] + /** + * Create a port mapping + * + * @param {String} routerIp + * @param {Number} intPort + * @param {Number} extPort + * @param {Number} ttl + * @param {Function} callback + */ + createMapping (routerIp = undefined, intPort, extPort, ttl, callback) { + const client = natUpnp.createClient() + waterfall([ + (cb) => client.externalIp((err, ip) => { + if (err) { + return callback(err) } + const mapping = this.newMapping(intPort) + mapping.externalIp = ip + cb(null, mapping) + }), + (mapping, cb) => { + client.portMapping({ + private: intPort, + public: extPort, + ttl + }, (err) => { + if (err) { + this.log.err(err) + return cb(err) + } + + mapping.externalPort = extPort + mapping.internalPort = intPort + mapping.ttl = ttl + // get the internal ip of the interface + // we're using to make the request + const internalIp = utils.longestPrefixMatch(utils.getPrivateIps(), routerIp) + mapping.internalIp = internalIp + cb(null, mapping) + }) } - }).catch(function (err) { - return Promise.reject(err) + ], (err, mapping) => { + client.close() // should be closed immediately + if (err) { + return callback(err) + } + callback(null, mapping) }) -} - -/** - * A public version of _getUpnpControlUrl that suppresses the Promise rejection, - * and replaces it with undefined. This is useful outside this module in a - * Promise.all(), while inside we want to propagate the errors upwards - * - * @return {Promise} A promise for the URL, undefined if not supported - */ -const getUpnpControlUrl = function () { - return _getUpnpControlUrl().catch(function (err) {}) -} + } -/** - * Send a UPnP SSDP request on the network and collects responses - * - * @return {Promise} A promise that fulfills with an array of SSDP response, - * or rejects on timeout - */ -const sendSsdpRequest = function () { - const ssdpResponses = [] - const socket = dgram.createSocket('udp4') - // Fulfill when we get any reply (failure is on timeout or invalid parsing) - socket.on('onData', function (ssdpResponse) { - ssdpResponses.push(ssdpResponse.data) - }) - // Bind a socket and send the SSDP request - socket.bind('0.0.0.0', 0, err => { - if (err) return - // Construct and send a UPnP SSDP message - const ssdpStr = 'M-SEARCH * HTTP/1.1\r\n' + - 'HOST: 239.255.255.250:1900\r\n' + - 'MAN: "ssdp:discover"\r\n' + - 'MX: 3\r\n' + - 'ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n' - const ssdpBuffer = utils.stringToArrayBuffer(ssdpStr) - socket.send(ssdpBuffer, 1900, '239.255.255.250') - }) - // Collect SSDP responses for 3 seconds before timing out - return new Promise(function (resolve, reject) { - setTimeout(function () { - if (ssdpResponses.length > 0) { - resolve(ssdpResponses) - } else { - resolve(new Error('SSDP timeout')) + _internalDeleteMapping (intPort, routerIp, extPort, callback) { + const client = natUpnp.createClient() + client.portUnmapping({ + public: extPort + }, (err) => { + client.close() // should be closed immediately + if (err) { + this.log.err(err) // don't crash on error } - }, 3000) - }) -} - -/** - * Fetch the control URL from the information provided in the SSDP response - * - * @param {ArrayBuffer} ssdpResponse The ArrayBuffer response to the SSDP message - * @return {string} The string of the control URL for the router - */ -const fetchControlUrl = function (ssdpResponse) { - // Promise to parse the location URL from the SSDP response, then send a POST - // xhr to the location URL to find the router's UPNP control URL - const _fetchControlUrl = new Promise(function (resolve, reject) { - const ssdpStr = utils.arrayBufferToString(ssdpResponse) - const startIndex = ssdpStr.indexOf('LOCATION:') + 9 - const endIndex = ssdpStr.indexOf('\n', startIndex) - const locationUrl = ssdpStr.substring(startIndex, endIndex).trim() - // Reject if there is no LOCATION header - if (startIndex === 8) { - resolve(new Error('No LOCATION header for UPnP device')) - return - } - // Get the XML device description at location URL - request - .get(locationUrl) - .type('xml') - .end((err, res) => { - if (err) { - return reject(err) - } - - if (!res.body.controlUrl) { - resolve(new Error('Could not parse control URL')) - return - } - - // Combine the controlUrl path with the locationUrl - const lcUrl = new URL(locationUrl).host - resolve(`http://${lcUrl}/${res.body.controlUrl}`) - }) - }) - // Give _fetchControlUrl 1 second before timing out - return Promise.race([ - utils.countdownReject(1000, 'Time out when retrieving description XML'), - _fetchControlUrl - ]) -} - -/** - * Send an AddPortMapping request to the router's control URL - * - * @param {string} controlUrl The control URL of the router - * @param {string} privateIp The private IP address of the user's computer - * @param {number} intPort The internal port on the computer to map to - * @param {number} extPort The external port on the router to map to - * @param {number} lifetime Seconds that the mapping will last - * @return {string} The response string to the AddPortMapping request - */ -const sendAddPortMapping = function (controlUrl, privateIp, intPort, extPort, lifetime) { - // Promise to send an AddPortMapping request to the control URL of the router - const _sendAddPortMapping = new Promise(function (resolve, reject) { - // The AddPortMapping SOAP request string - const apm = '' + - '' + - '' + - '' + - '' + extPort + '' + - 'UDP' + - '' + intPort + '' + - '' + privateIp + '' + - '1' + - 'uProxy UPnP' + - '' + lifetime + '' + - '' + - '' + - '' - // Create an XMLHttpRequest that encapsulates the SOAP string - request.post(controlUrl) - .type('xml') - .set('Content-Type', 'text/xml') - .set('SOAPAction', '"urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping"') - .send(apm, (err, res) => { - if (err) { return reject(err) } - resolve(res.body) - }) - - // Give _sendAddPortMapping 1 second to run before timing out - return Promise.race([ - utils.countdownReject(1000, 'AddPortMapping time out'), - _sendAddPortMapping - ]) - }) + return callback(null) + }) + } } -/** - * Send a DeletePortMapping request to the router's control URL - * - * @param {string} controlUrl The control URL of the router - * @param {number} extPort The external port of the mapping to delete - * @return {string} The response string to the AddPortMapping request - */ -const sendDeletePortMapping = function (controlUrl, extPort) { - // Promise to send an AddPortMapping request to the control URL of the router - const _sendDeletePortMapping = new Promise(function (resolve, reject) { - // The DeletePortMapping SOAP request string - const apm = '' + - '' + - '' + - '' + - '' + - '' + extPort + '' + - 'UDP' + - '' + - '' + - '' - // Create an XMLHttpRequest that encapsulates the SOAP string - request.post(controlUrl) - .type('xml') - .set('Content-Type', 'text/xml') - .set('SOAPAction', '"urn:schemas-upnp-org:service:WANIPConnection:1#DeletePortMapping"') - .send(apm, (err, res) => { - if (err) { - return reject(err) - } - resolve(res.body) - }) - }) - - // Give _sendDeletePortMapping 1 second to run before timing out - return Promise.race([ - utils.countdownReject(1000, 'DeletePortMapping time out'), - _sendDeletePortMapping - ]) -} -module.exports = { - probeSupport: probeSupport, - addMapping: addMapping, - deleteMapping: deleteMapping, - getUpnpControlUrl: getUpnpControlUrl -} +module.exports = NatPMP From 086eb5b138b909002bf0321fe9a8f1536cb71085 Mon Sep 17 00:00:00 2001 From: Dmitriy Ryajov Date: Sat, 26 May 2018 10:50:32 -0600 Subject: [PATCH 03/11] feat: removing mappins file - removing mappings file - adding jenkins ci --- ci/Jenkinsfile | 3 + src/mappers/mapper.js | 134 ++++++++++++++++++++++++++++++++++++++++++ src/mappers/pmp.js | 82 ++++++++++++++++++++++++++ test/node.js | 4 ++ test/pmp.js | 42 +++++++++++++ test/upnp.js | 43 ++++++++++++++ 6 files changed, 308 insertions(+) create mode 100644 ci/Jenkinsfile create mode 100644 src/mappers/mapper.js create mode 100644 src/mappers/pmp.js create mode 100644 test/node.js create mode 100644 test/pmp.js create mode 100644 test/upnp.js diff --git a/ci/Jenkinsfile b/ci/Jenkinsfile new file mode 100644 index 0000000..6621675 --- /dev/null +++ b/ci/Jenkinsfile @@ -0,0 +1,3 @@ + +// Warning: This file is automatically synced from https://github.com/ipfs/ci-sync so if you want to change it, please change it there and ask someone to sync all repositories. +javascript() \ No newline at end of file diff --git a/src/mappers/mapper.js b/src/mappers/mapper.js new file mode 100644 index 0000000..a225488 --- /dev/null +++ b/src/mappers/mapper.js @@ -0,0 +1,134 @@ +'use strict' +const debug = require('debug') +const tryEach = require('async/tryEach') + +const utils = require('../utils') + +class Mapper { + constructor (name, port) { + this.name = name + this.port = port + this.mappings = {} + + this.log = debug(`nat-puncher:${name}`) + this.log.err = debug(`nat-puncher:${name}:error`) + } + + newMapping (port) { + return { + routerIp: null, + internalIp: null, + internalPort: port, + externalIp: null, // Only provided by PCP, undefined for other protocols + externalPort: -1, // The actual external port of the mapping, -1 on failure + ttl: null, // The actual (response) lifetime of the mapping + protocol: this.name, // The protocol used to make the mapping ('natPmp', 'pcp', 'upnp') + nonce: null, // Only for PCP; the nonce field for deletion + errInfo: null // Error message if failure; currently used only for UPnP + } + } + + addMapping (intPort, extPort, ttl, activeMappings, routerIpCache, callback) { + // If lifetime is zero, we want to refresh every 24 hours + ttl = !ttl ? 24 * 60 * 60 : ttl + + // Try matchedRouterIps first (routerIpCache + router IPs that match the + // user's IPs), then otherRouterIps if it doesn't work. This avoids flooding + // the local network with requests + const matchedRouterIps = new Set([ + ...routerIpCache, + ...utils.filterRouterIps(utils.getPrivateIps()) + ]) + + tryEach([ + // try a routers that match our ip first + (cb) => this._sendRequests(intPort, + extPort, + [...matchedRouterIps], + routerIpCache, + ttl, + cb), + // fallback to trying all known router addrs + (cb) => this._sendRequests(intPort, + extPort, + utils.ROUTER_IPS.filter((ip) => !matchedRouterIps.has(ip)), + routerIpCache, + ttl, + cb) + ], (err, mapping) => { + if (err) { + return callback(err) + } + + // If the actual ttl is less than the requested ttl, + // setTimeout to refresh the mapping when it expires + const realTtl = ttl - (mapping ? mapping.ttl : 0) + if (mapping && realTtl > 0) { + setTimeout(this.addMapping.bind(this, + intPort, + mapping.externalPort, + realTtl, + activeMappings), + mapping.ttl * 1000) + } else if (mapping && ttl <= 0) { + // If the original ttl is 0, refresh every 24 hrs indefinitely + setTimeout(this.addMapping.bind(this, + intPort, + mapping.externalPort, + 0, + activeMappings), + 24 * 60 * 60 * 1000) + } + + activeMappings[mapping.externalPort] = this + this.mappings[extPort] = mapping + callback(null, mapping) + }) + } + + createMapping (routerIp, intPort, extPort, lifetime, cb) { + cb(new Error('Not implemented!')) + } + + _sendRequests (intPort, extPort, routerIps, routerIpCache, reqLifetime, callback) { + tryEach(routerIps.map((ip) => { + return (cb) => { + this.createMapping(ip, + intPort, + extPort, + reqLifetime, + (err, mapping) => { + if (err) { + this.log.err(err) + return cb(err) + } + routerIpCache.push(ip) + cb(null, mapping) + }) + } + }), callback) + } + + deleteMapping (port, activeMappings, callback) { + const mapping = this.mappings[port] + this._internalDeleteMapping(mapping.internalPort, + mapping.routerIp, + mapping.externalPort, + (err) => { + if (err) { + return callback(err) + } + + // delete the mappings + delete this.mappings[port] + delete activeMappings[port] + callback() + }) + } + + _internalDeleteMapping (intPort, routerIp, extPort, callback) { + callback(new Error('Not implemented!')) + } +} + +module.exports = Mapper diff --git a/src/mappers/pmp.js b/src/mappers/pmp.js new file mode 100644 index 0000000..e36e3eb --- /dev/null +++ b/src/mappers/pmp.js @@ -0,0 +1,82 @@ +'use strict' + +const natPmp = require('nat-pmp') +const waterfall = require('async/waterfall') + +const Mapper = require('./mapper') +const utils = require('../utils') + +const NAT_PMP_PROBE_PORT = 55555 + +class NatPMP extends Mapper { + constructor () { + super('natPmp', NAT_PMP_PROBE_PORT) + } + + /** + * Create a port mapping + * + * @param {String} routerIp + * @param {Number} intPort + * @param {Number} extPort + * @param {Number} ttl + * @param {Function} callback + */ + createMapping (routerIp, intPort, extPort, ttl, callback) { + const client = natPmp.connect(routerIp) + const mapping = this.newMapping(intPort) + mapping.routerIp = routerIp + waterfall([ + (cb) => client.externalIp((err, info) => { + if (err) { + return callback(err) + } + mapping.externalIp = info.ip.join('.') + cb(null, mapping) + }), + (mapping, cb) => { + client.portMapping({ + private: intPort, + public: extPort, + ttl + }, (err, info) => { + if (err) { + this.log.err(err) + return cb(err) + } + + mapping.externalPort = info.public + mapping.internalPort = info.private + mapping.ttl = info.ttl + // get the internal ip of the interface + // we're using to make the request + const internalIp = utils.longestPrefixMatch(utils.getPrivateIps(), routerIp) + mapping.internalIp = internalIp + cb(null, mapping) + }) + } + ], (err, mapping) => { + client.close() // should be closed immediately + if (err) { + return callback(err) + } + callback(null, mapping) + }) + } + + _internalDeleteMapping (intPort, routerIp, extPort, callback) { + const client = natPmp.connect(routerIp) + client.portUnmapping({ + private: intPort, + public: extPort + }, (err, info) => { + client.close() // should be closed immediately + if (err) { + return callback(err) + } + return callback(null, err) + }) + } +} + +module.exports = NatPMP diff --git a/test/node.js b/test/node.js new file mode 100644 index 0000000..d4f211b --- /dev/null +++ b/test/node.js @@ -0,0 +1,4 @@ +'use strict' + +require('./pmp') +require('./upnp') diff --git a/test/pmp.js b/test/pmp.js new file mode 100644 index 0000000..188da5b --- /dev/null +++ b/test/pmp.js @@ -0,0 +1,42 @@ +/* eslint-env mocha */ +'use strict' + +const chai = require('chai') +const dirtyChai = require('dirty-chai') +const NatPMP = require('../src/mappers/pmp') + +chai.use(dirtyChai) +const expect = chai.expect + +describe('NAT-PMP tests', () => { + let natPMP + before(() => { + natPMP = new NatPMP() + }) + + it('should add mapping', (done) => { + natPMP.addMapping(50566, 50566, 0, {}, [], (error, mapping) => { + expect(error).to.not.exist() + expect(mapping.internalPort).to.be.eql(50566) + done() + }) + }).timeout(5 * 10000) + + it('should delete a mapping', (done) => { + let mapping = { + errInfo: null, + externalIp: '186.4.10.102', + externalPort: 50566, + internalIp: '10.0.0.107', + internalPort: 50566, + protocol: 'natPmp', + routerIp: '10.0.0.1', + ttl: 86400 + } + natPMP.mappings[50566] = mapping + natPMP.deleteMapping(50566, {}, (error) => { + expect(error).to.not.exist() + done() + }) + }).timeout(5 * 10000) +}) diff --git a/test/upnp.js b/test/upnp.js new file mode 100644 index 0000000..0a5365b --- /dev/null +++ b/test/upnp.js @@ -0,0 +1,43 @@ +/* eslint-env mocha */ +'use strict' + +const chai = require('chai') +const dirtyChai = require('dirty-chai') +const NatUpnp = require('../src/mappers/upnp') + +chai.use(dirtyChai) +const expect = chai.expect + +describe('Nat UpNP tests', () => { + let natUpnp + before(() => { + natUpnp = new NatUpnp() + }) + + it('should add mapping', (done) => { + natUpnp.addMapping(50567, 50567, 0, {}, [], (error, mapping) => { + expect(error).to.not.exist() + expect(mapping.internalPort).to.be.eql(50567) + done() + }) + }).timeout(5 * 10000) + + it('should delete a mapping', (done) => { + const mapping = { + errInfo: null, + externalIp: '186.4.10.102', + externalPort: 50567, + internalIp: '10.0.0.107', + internalPort: 50567, + nonce: null, + protocol: 'natPmp', + routerIp: '10.0.0.1', + ttl: 86400 + } + natUpnp.mappings[50567] = mapping + natUpnp.deleteMapping(50567, {}, (error) => { + expect(error).to.not.exist() + done() + }) + }).timeout(5 * 10000) +}) From b138ea5f1a2339a5c77489191a0909267f1b4001 Mon Sep 17 00:00:00 2001 From: Dmitriy Ryajov Date: Sat, 26 May 2018 10:57:42 -0600 Subject: [PATCH 04/11] test: use random ports in tests --- test/pmp.js | 24 ++++++++++-------------- test/upnp.js | 25 ++++++++++--------------- 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/test/pmp.js b/test/pmp.js index 188da5b..a7f5e5e 100644 --- a/test/pmp.js +++ b/test/pmp.js @@ -8,33 +8,29 @@ const NatPMP = require('../src/mappers/pmp') chai.use(dirtyChai) const expect = chai.expect +// TODO: provisional tests +// need to figure out a more +// robust way of testing this describe('NAT-PMP tests', () => { let natPMP + let natmapping before(() => { natPMP = new NatPMP() }) it('should add mapping', (done) => { - natPMP.addMapping(50566, 50566, 0, {}, [], (error, mapping) => { + let port = ~~(Math.random() * 65536) + natPMP.addMapping(port, port, 0, {}, [], (error, mapping) => { expect(error).to.not.exist() - expect(mapping.internalPort).to.be.eql(50566) + expect(mapping.internalPort).to.be.eql(port) + natmapping = mapping done() }) }).timeout(5 * 10000) it('should delete a mapping', (done) => { - let mapping = { - errInfo: null, - externalIp: '186.4.10.102', - externalPort: 50566, - internalIp: '10.0.0.107', - internalPort: 50566, - protocol: 'natPmp', - routerIp: '10.0.0.1', - ttl: 86400 - } - natPMP.mappings[50566] = mapping - natPMP.deleteMapping(50566, {}, (error) => { + natPMP.mappings[natmapping.externalPort] = natmapping + natPMP.deleteMapping(natmapping.externalPort, {}, (error) => { expect(error).to.not.exist() done() }) diff --git a/test/upnp.js b/test/upnp.js index 0a5365b..4796670 100644 --- a/test/upnp.js +++ b/test/upnp.js @@ -8,34 +8,29 @@ const NatUpnp = require('../src/mappers/upnp') chai.use(dirtyChai) const expect = chai.expect +// TODO: provisional tests +// need to figure out a more +// robust way of testing this describe('Nat UpNP tests', () => { let natUpnp + let natmapping before(() => { natUpnp = new NatUpnp() }) it('should add mapping', (done) => { - natUpnp.addMapping(50567, 50567, 0, {}, [], (error, mapping) => { + let port = ~~(Math.random() * 65536) + natUpnp.addMapping(port, port, 0, {}, [], (error, mapping) => { expect(error).to.not.exist() - expect(mapping.internalPort).to.be.eql(50567) + expect(mapping.internalPort).to.be.eql(port) + natmapping = mapping done() }) }).timeout(5 * 10000) it('should delete a mapping', (done) => { - const mapping = { - errInfo: null, - externalIp: '186.4.10.102', - externalPort: 50567, - internalIp: '10.0.0.107', - internalPort: 50567, - nonce: null, - protocol: 'natPmp', - routerIp: '10.0.0.1', - ttl: 86400 - } - natUpnp.mappings[50567] = mapping - natUpnp.deleteMapping(50567, {}, (error) => { + natUpnp.mappings[natmapping.externalPort] = natmapping + natUpnp.deleteMapping(natmapping.externalPort, {}, (error) => { expect(error).to.not.exist() done() }) From 8a3e5e0e8f1fa97984c0f7ea3b6a968045451eae Mon Sep 17 00:00:00 2001 From: Dmitriy Ryajov Date: Sun, 27 May 2018 22:00:13 -0600 Subject: [PATCH 05/11] feat: simplify implementation --- package.json | 3 +- requirements.md | 9 ++ src/index.js | 68 ++++++------- src/mappers/mapper.js | 104 ++++---------------- src/mappers/pmp.js | 113 +++++++++++---------- src/mappers/upnp.js | 89 ++++++++--------- src/utils.js | 223 ------------------------------------------ test/pmp.js | 5 +- test/upnp.js | 5 +- 9 files changed, 174 insertions(+), 445 deletions(-) create mode 100644 requirements.md delete mode 100644 src/utils.js diff --git a/package.json b/package.json index 18e1d84..b283090 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "dgram": "^1.0.1", "ipaddr.js": "^1.7.0", "nat-pmp": "^1.0.0", - "nat-upnp": "^1.1.1" + "nat-upnp": "^1.1.1", + "network": "^0.4.1" }, "devDependencies": { "aegir": "^13.1.0", diff --git a/requirements.md b/requirements.md new file mode 100644 index 0000000..4d3e281 --- /dev/null +++ b/requirements.md @@ -0,0 +1,9 @@ +# Requirements + +- mapper should be able to hold multiple mappings for the same external + port but different external ips + - for example, if the client moves around (laptops, mobile devices) and connects + from different access points, the mapper should be able to detect if we're using + a different external ip/getaway for which we don't have a prior mapping and add one +- mapper should be able to auto-renew after a timeout +- mapper should be plugable - different nat techniques should be easy to adapt diff --git a/src/index.js b/src/index.js index 24eb8eb..9b302c1 100644 --- a/src/index.js +++ b/src/index.js @@ -1,66 +1,68 @@ 'use strict' -const utils = require('./utils') const NatPmp = require('./mappers/pmp') -// const PCP = require('./pcp') const UPnP = require('./mappers/upnp') const EE = require('events') const tryEach = require('async/tryEach') +const parallel = require('async/parallel') +const waterfall = require('async/waterfall') +const network = require('network') class NatManager extends EE { - constructor () { + constructor (mappers) { super() - this.mappers = [ + this.mappers = mappers || [ new NatPmp(), new UPnP() ] this.activeMappings = {} - this.routerIpCache = new Set() } - addMapping (intPort, extPort, lifetime) { + addMapping (intPort, extPort, lifetime, callback) { tryEach(this.mappers.map((mapper) => { return (cb) => { return mapper.addMapping(intPort, extPort, lifetime, - this.activeMappings, - this.routerIpCache) + (err, mapping) => { + if (err) { + return callback(err) + } + + const mapKey = `${mapping.externalIp}:${mapping.externalPort}` + this.activeMappings[mapKey] = mapper + callback(null, mapping) + }) } })) } - deleteMapping (extPort, callback) { - const mapper = this.activeMappings[extPort] - if (mapper) { - mapper.deleteMapping(extPort, - this.routerIpCache, - callback) + deleteMapping (extPort, extIp, callback) { + if (typeof extIp === 'function') { + callback = extIp + extIp = undefined } - } - - getActiveMappings () { - return this.activeMappings - } - getRouterIpCache () { - return [...this.routerIpCache] - } - - getPrivateIps () { - return utils.getPrivateIps() + waterfall([ + (cb) => extIp + ? cb(null, extIp) + : network.get_public_ip(cb), + (ip, cb) => { + const mapper = this.activeMappings[`${ip}:${extPort}`] + if (mapper) { + mapper.deleteMapping(extPort, callback) + } + } + ], callback) } - close () { - return new Promise((resolve, reject) => { - for (let [extPort, mapper] of this.activeMappings) { - mapper.deleteMapping(extPort, - this.activeMappings, - this.routerIpCache) - } - }) + close (callback) { + parallel(Object.keys(this.activeMappings).map((key) => { + const [ip, port] = key.split(':') + return (cb) => this.activeMappings[key].deleteMapping(port, ip, cb) + }), callback) } } diff --git a/src/mappers/mapper.js b/src/mappers/mapper.js index a225488..28232fe 100644 --- a/src/mappers/mapper.js +++ b/src/mappers/mapper.js @@ -1,10 +1,7 @@ 'use strict' const debug = require('debug') -const tryEach = require('async/tryEach') -const utils = require('../utils') - -class Mapper { +class BaseMapper { constructor (name, port) { this.name = name this.port = port @@ -20,7 +17,7 @@ class Mapper { internalIp: null, internalPort: port, externalIp: null, // Only provided by PCP, undefined for other protocols - externalPort: -1, // The actual external port of the mapping, -1 on failure + externalPort: null, // The actual external port of the mapping, -1 on failure ttl: null, // The actual (response) lifetime of the mapping protocol: this.name, // The protocol used to make the mapping ('natPmp', 'pcp', 'upnp') nonce: null, // Only for PCP; the nonce field for deletion @@ -28,91 +25,29 @@ class Mapper { } } - addMapping (intPort, extPort, ttl, activeMappings, routerIpCache, callback) { + addMapping (intPort, extPort, ttl, callback) { // If lifetime is zero, we want to refresh every 24 hours ttl = !ttl ? 24 * 60 * 60 : ttl - // Try matchedRouterIps first (routerIpCache + router IPs that match the - // user's IPs), then otherRouterIps if it doesn't work. This avoids flooding - // the local network with requests - const matchedRouterIps = new Set([ - ...routerIpCache, - ...utils.filterRouterIps(utils.getPrivateIps()) - ]) - - tryEach([ - // try a routers that match our ip first - (cb) => this._sendRequests(intPort, - extPort, - [...matchedRouterIps], - routerIpCache, - ttl, - cb), - // fallback to trying all known router addrs - (cb) => this._sendRequests(intPort, - extPort, - utils.ROUTER_IPS.filter((ip) => !matchedRouterIps.has(ip)), - routerIpCache, - ttl, - cb) - ], (err, mapping) => { - if (err) { - return callback(err) - } - - // If the actual ttl is less than the requested ttl, - // setTimeout to refresh the mapping when it expires - const realTtl = ttl - (mapping ? mapping.ttl : 0) - if (mapping && realTtl > 0) { - setTimeout(this.addMapping.bind(this, - intPort, - mapping.externalPort, - realTtl, - activeMappings), - mapping.ttl * 1000) - } else if (mapping && ttl <= 0) { - // If the original ttl is 0, refresh every 24 hrs indefinitely - setTimeout(this.addMapping.bind(this, - intPort, - mapping.externalPort, - 0, - activeMappings), - 24 * 60 * 60 * 1000) - } - - activeMappings[mapping.externalPort] = this - this.mappings[extPort] = mapping - callback(null, mapping) - }) + this.createMapping(intPort, + extPort, + ttl, + (err, mapping) => { + if (err) { + this.log.err(err) + return callback(err) + } + this.mappings[`${mapping.externalIp}:${mapping.externalPort}`] = mapping + callback(null, mapping) + }) } - createMapping (routerIp, intPort, extPort, lifetime, cb) { + createMapping (intPort, extPort, lifetime, cb) { cb(new Error('Not implemented!')) } - _sendRequests (intPort, extPort, routerIps, routerIpCache, reqLifetime, callback) { - tryEach(routerIps.map((ip) => { - return (cb) => { - this.createMapping(ip, - intPort, - extPort, - reqLifetime, - (err, mapping) => { - if (err) { - this.log.err(err) - return cb(err) - } - routerIpCache.push(ip) - cb(null, mapping) - }) - } - }), callback) - } - - deleteMapping (port, activeMappings, callback) { - const mapping = this.mappings[port] + deleteMapping (mapping, callback) { this._internalDeleteMapping(mapping.internalPort, - mapping.routerIp, mapping.externalPort, (err) => { if (err) { @@ -120,15 +55,14 @@ class Mapper { } // delete the mappings - delete this.mappings[port] - delete activeMappings[port] + delete this.mappings[`${mapping.externalIp}:${mapping.externalPort}`] callback() }) } - _internalDeleteMapping (intPort, routerIp, extPort, callback) { + _internalDeleteMapping (intPort, extPort, callback) { callback(new Error('Not implemented!')) } } -module.exports = Mapper +module.exports = BaseMapper diff --git a/src/mappers/pmp.js b/src/mappers/pmp.js index e36e3eb..4812f00 100644 --- a/src/mappers/pmp.js +++ b/src/mappers/pmp.js @@ -2,79 +2,86 @@ const natPmp = require('nat-pmp') const waterfall = require('async/waterfall') +const network = require('network') const Mapper = require('./mapper') -const utils = require('../utils') - -const NAT_PMP_PROBE_PORT = 55555 class NatPMP extends Mapper { constructor () { - super('natPmp', NAT_PMP_PROBE_PORT) + super('nat-pmp') } /** - * Create a port mapping + * Create port mapping * - * @param {String} routerIp - * @param {Number} intPort - * @param {Number} extPort - * @param {Number} ttl + * @param {number} intPort + * @param {number} extPort + * @param {number} ttl * @param {Function} callback + * @returns {undefined} */ - createMapping (routerIp, intPort, extPort, ttl, callback) { - const client = natPmp.connect(routerIp) - const mapping = this.newMapping(intPort) - mapping.routerIp = routerIp - waterfall([ - (cb) => client.externalIp((err, info) => { - if (err) { - return callback(err) - } - mapping.externalIp = info.ip.join('.') - cb(null, mapping) - }), - (mapping, cb) => { - client.portMapping({ - private: intPort, - public: extPort, - ttl - }, (err, info) => { - if (err) { - this.log.err(err) - return cb(err) - } - - mapping.externalPort = info.public - mapping.internalPort = info.private - mapping.ttl = info.ttl - // get the internal ip of the interface - // we're using to make the request - const internalIp = utils.longestPrefixMatch(utils.getPrivateIps(), routerIp) - mapping.internalIp = internalIp - cb(null, mapping) - }) - } - ], (err, mapping) => { - client.close() // should be closed immediately + createMapping (intPort, extPort, ttl, callback) { + network.get_active_interface((err, activeIf) => { if (err) { return callback(err) } - callback(null, mapping) + + const client = natPmp.connect(activeIf.gateway_ip) + const mapping = this.newMapping(intPort) + mapping.routerIp = activeIf.gateway_ip + waterfall([ + (cb) => client.externalIp((err, info) => { + if (err) { + return callback(err) + } + mapping.externalIp = info.ip.join('.') + cb(null, mapping) + }), + (mapping, cb) => { + client.portMapping({ + private: intPort, + public: extPort, + ttl + }, (err, info) => { + if (err) { + this.log.err(err) + return cb(err) + } + + mapping.externalPort = info.public + mapping.internalPort = info.private + mapping.internalIp = activeIf.ip_address + mapping.ttl = info.ttl + cb(null, mapping) + }) + } + ], (err, mapping) => { + client.close() // should be closed immediately + if (err) { + return callback(err) + } + callback(null, mapping) + }) }) } - _internalDeleteMapping (intPort, routerIp, extPort, callback) { - const client = natPmp.connect(routerIp) - client.portUnmapping({ - private: intPort, - public: extPort - }, (err, info) => { - client.close() // should be closed immediately + _internalDeleteMapping (intPort, extPort, callback) { + network.get_gateway_ip((err, routerIp) => { if (err) { return callback(err) } - return callback(null, err) + + const client = natPmp.connect(routerIp) + client.portUnmapping({ + private: intPort, + public: extPort + }, (err, info) => { + client.close() // should be closed immediately + if (err) { + return callback(err) + } + return callback(null, err) + }) }) } } diff --git a/src/mappers/upnp.js b/src/mappers/upnp.js index cc99e34..bd01c40 100644 --- a/src/mappers/upnp.js +++ b/src/mappers/upnp.js @@ -2,68 +2,69 @@ const natUpnp = require('nat-upnp') const waterfall = require('async/waterfall') +const network = require('network') const Mapper = require('./mapper') -const utils = require('../utils') - -const NAT_PMP_PROBE_PORT = 55555 class NatPMP extends Mapper { constructor () { - super('natPmp', NAT_PMP_PROBE_PORT) + super('unpn') } /** * Create a port mapping * - * @param {String} routerIp - * @param {Number} intPort - * @param {Number} extPort - * @param {Number} ttl + * @param {number} intPort + * @param {number} extPort + * @param {number} ttl * @param {Function} callback + * @returns {undefined} */ - createMapping (routerIp = undefined, intPort, extPort, ttl, callback) { - const client = natUpnp.createClient() - waterfall([ - (cb) => client.externalIp((err, ip) => { - if (err) { - return callback(err) - } - const mapping = this.newMapping(intPort) - mapping.externalIp = ip - cb(null, mapping) - }), - (mapping, cb) => { - client.portMapping({ - private: intPort, - public: extPort, - ttl - }, (err) => { - if (err) { - this.log.err(err) - return cb(err) - } - - mapping.externalPort = extPort - mapping.internalPort = intPort - mapping.ttl = ttl - // get the internal ip of the interface - // we're using to make the request - const internalIp = utils.longestPrefixMatch(utils.getPrivateIps(), routerIp) - mapping.internalIp = internalIp - cb(null, mapping) - }) - } - ], (err, mapping) => { - client.close() // should be closed immediately + createMapping (intPort, extPort, ttl, callback) { + network.get_active_interface((err, activeIf) => { if (err) { return callback(err) } - callback(null, mapping) + + const client = natUpnp.createClient() + waterfall([ + (cb) => client.externalIp((err, ip) => { + if (err) { + return callback(err) + } + const mapping = this.newMapping(intPort) + mapping.externalIp = ip + cb(null, mapping) + }), + (mapping, cb) => { + client.portMapping({ + private: intPort, + public: extPort, + ttl + }, (err) => { + if (err) { + this.log.err(err) + return cb(err) + } + + mapping.externalPort = extPort + mapping.internalPort = intPort + mapping.ttl = ttl + mapping.internalIp = activeIf.ip_address + cb(null, mapping) + }) + } + ], (err, mapping) => { + client.close() // should be closed immediately + if (err) { + return callback(err) + } + callback(null, mapping) + }) }) } - _internalDeleteMapping (intPort, routerIp, extPort, callback) { + _internalDeleteMapping (intPort, extPort, callback) { const client = natUpnp.createClient() client.portUnmapping({ public: extPort diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index 5f4ef64..0000000 --- a/src/utils.js +++ /dev/null @@ -1,223 +0,0 @@ -'use strict' -const ipaddr = require('ipaddr.js') -const os = require('os') - -/** - * List of popular router default IPs - * Used as destination addresses for NAT-PMP and PCP requests - * http://www.techspot.com/guides/287-default-router-ip-addresses/ - */ -const ROUTER_IPS = ['192.168.1.1', '192.168.2.1', '192.168.11.1', - '192.168.0.1', '192.168.0.30', '192.168.0.50', '192.168.20.1', - '192.168.30.1', '192.168.62.1', '192.168.100.1', '192.168.102.1', - '192.168.1.254', '192.168.10.1', '192.168.123.254', '192.168.4.1', - '10.0.0.1', '10.0.1.1', '10.1.1.1', '10.0.0.13', '10.0.0.2', - '10.0.0.138' -] - -/** - * Return the private IP addresses of the computer - */ -function getPrivateIps () { - const ifs = os.networkInterfaces() - return Object.keys(ifs) - .map(k => ifs[k]) - .reduce((a, b) => a.concat(b), []) - .filter(i => !i.internal) - .map(i => i.address) - .filter(a => ipaddr.IPv4.isValid(a)) -} - -/** -* Filters routerIps for only those that match any of the user's IPs in privateIps -* i.e. The longest prefix matches of the router IPs with each user IP* -* -* @param {Array} privateIps Private IPs to match router IPs to -* @return {Array} Router IPs that matched (one per private IP) -*/ -function filterRouterIps (privateIps) { - let routerIps = [] - privateIps.forEach(function (privateIp) { - routerIps.push(longestPrefixMatch(ROUTER_IPS, privateIp)) - }) - return routerIps -} - -/** - * Creates an ArrayBuffer with a compact matrix notation, i.e. - * [[bits, byteOffset, value], - * [8, 0, 1], //=> DataView.setInt8(0, 1) - * ... ] - * - * @param {number} bytes Size of the ArrayBuffer in bytes - * @param {Array>} matrix Matrix of values for the ArrayBuffer - * @return {ArrayBuffer} An ArrayBuffer constructed from matrix - */ -const createArrayBuffer = function (bytes, matrix) { - const buffer = new ArrayBuffer(bytes) - const view = new DataView(buffer) - for (let i = 0; i < matrix.length; i++) { - const row = matrix[i] - if (row[0] === 8) { - view.setInt8(row[1], row[2]) - } else if (row[0] === 16) { - view.setInt16(row[1], row[2], false) - } else if (row[0] === 32) { - view.setInt32(row[1], row[2], false) - } else { - console.error('Invalid parameters to createArrayBuffer') - } - } - return Buffer.from(buffer) -} - -/** - * Return a promise that rejects in a given time with an Error message, - * and can call a callback function before rejecting - * - * @param {number} time Time in seconds - * @param {number} msg Message to encapsulate in the rejected Error - * @param {function} callback Function to call before rejecting - * @return {Promise} A promise that will reject in the given time - */ -const countdownReject = function (time, msg, callback) { - return new Promise(function (resolve, reject) { - setTimeout(function () { - if (callback !== undefined) { - callback() - } - reject(new Error(msg)) - }, time) - }) -} - -/** - * Close the OS-level sockets and discard its Freedom object - * - * @param {freedom_UdpSocket.Socket} socket The socket object to close - */ -const closeSocket = function (socket) { - socket.close() -} - -/** - * Takes a list of IP addresses and an IP address, and returns the longest prefix - * match in the IP list with the IP - * - * @param {Array} ipList List of IP addresses to find the longest prefix match in - * @param {string} matchIp The router's IP address as a string - * @return {string} The IP from the given list with the longest prefix match - */ -const longestPrefixMatch = function (ipList, matchIp) { - const prefixMatches = [] - matchIp = ipaddr.IPv4.parse(matchIp) - for (let i = 0; i < ipList.length; i++) { - const ip = ipaddr.IPv4.parse(ipList[i]) - // Use ipaddr.js to find the longest prefix length (mask length) - for (let mask = 1; mask < 32; mask++) { - if (!ip.match(matchIp, mask)) { - prefixMatches.push(mask - 1) - break - } - } - } - // Find the argmax for prefixMatches, i.e. the index of the correct private IP - const maxIndex = prefixMatches.indexOf(Math.max.apply(null, prefixMatches)) - const correctIp = ipList[maxIndex] - return correctIp -} - -/** - * Return a random integer in a specified range - * - * @param {number} min Lower bound for the random integer - * @param {number} max Upper bound for the random integer - * @return {number} A random number between min and max - */ -const randInt = function (min, max) { - return Math.floor(Math.random() * (max - min + 1)) + min -} - -/** - * Convert an ArrayBuffer to a UTF-8 string - * @public - * @method arrayBufferToString - * @param {ArrayBuffer} buffer ArrayBuffer to convert - * @return {string} A string converted from the ArrayBuffer - */ -const arrayBufferToString = function (buffer) { - const bytes = new Uint8Array(buffer) - const a = [] - for (let i = 0; i < bytes.length; ++i) { - a.push(String.fromCharCode(bytes[i])) - } - return a.join('') -} - -/** - * Convert a UTF-8 string to an ArrayBuffer - * - * @param {string} s String to convert - * @return {ArrayBuffer} An ArrayBuffer containing the string data - */ -const stringToArrayBuffer = function (s) { - const buffer = new ArrayBuffer(s.length) - const bytes = new Uint8Array(buffer) - for (let i = 0; i < s.length; ++i) { - bytes[i] = s.charCodeAt(i) - } - return Buffer.from(buffer) -} - -/** - * Returns the difference between two arrays - * - * @param {Array} listA - * @param {Array} listB - * @return {Array} The difference array - */ -const arrDiff = function (listA, listB) { - const diff = [] - listA.forEach(function (a) { - if (listB.indexOf(a) === -1) { - diff.push(a) - } - }) - return diff -} - -/** - * Adds two arrays, but doesn't include repeated elements - * - * @param {Array} listA - * @param {Array} listB - * @return {Array} The sum of the two arrays with no duplicates - */ -const arrAdd = function (listA, listB) { - const sum = [] - listA.forEach(function (a) { - if (sum.indexOf(a) === -1) { - sum.push(a) - } - }) - listB.forEach(function (b) { - if (sum.indexOf(b) === -1) { - sum.push(b) - } - }) - return sum -} -module.exports = { - ROUTER_IPS, - createArrayBuffer, - countdownReject, - closeSocket, - longestPrefixMatch, - randInt, - arrayBufferToString, - stringToArrayBuffer, - arrAdd, - arrDiff, - getPrivateIps, - filterRouterIps -} diff --git a/test/pmp.js b/test/pmp.js index a7f5e5e..ef084fb 100644 --- a/test/pmp.js +++ b/test/pmp.js @@ -20,7 +20,7 @@ describe('NAT-PMP tests', () => { it('should add mapping', (done) => { let port = ~~(Math.random() * 65536) - natPMP.addMapping(port, port, 0, {}, [], (error, mapping) => { + natPMP.addMapping(port, port, 0, (error, mapping) => { expect(error).to.not.exist() expect(mapping.internalPort).to.be.eql(port) natmapping = mapping @@ -29,8 +29,7 @@ describe('NAT-PMP tests', () => { }).timeout(5 * 10000) it('should delete a mapping', (done) => { - natPMP.mappings[natmapping.externalPort] = natmapping - natPMP.deleteMapping(natmapping.externalPort, {}, (error) => { + natPMP.deleteMapping(natmapping, (error) => { expect(error).to.not.exist() done() }) diff --git a/test/upnp.js b/test/upnp.js index 4796670..efc7b71 100644 --- a/test/upnp.js +++ b/test/upnp.js @@ -20,7 +20,7 @@ describe('Nat UpNP tests', () => { it('should add mapping', (done) => { let port = ~~(Math.random() * 65536) - natUpnp.addMapping(port, port, 0, {}, [], (error, mapping) => { + natUpnp.addMapping(port, port, 0, (error, mapping) => { expect(error).to.not.exist() expect(mapping.internalPort).to.be.eql(port) natmapping = mapping @@ -29,8 +29,7 @@ describe('Nat UpNP tests', () => { }).timeout(5 * 10000) it('should delete a mapping', (done) => { - natUpnp.mappings[natmapping.externalPort] = natmapping - natUpnp.deleteMapping(natmapping.externalPort, {}, (error) => { + natUpnp.deleteMapping(natmapping, (error) => { expect(error).to.not.exist() done() }) From 7777724f7599681d017c19fdc5bc052e13323cc4 Mon Sep 17 00:00:00 2001 From: Dmitriy Ryajov Date: Sun, 27 May 2018 22:31:11 -0600 Subject: [PATCH 06/11] chore: rename to mngr --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b283090..bc9746c 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "js-libp2p-nat-mgr", + "name": "js-libp2p-nat-mngr", "version": "1.0.0", "description": "", "main": "src/index.js", From 9a15142cf84e99d41f11fd378744bb1373a1ddfd Mon Sep 17 00:00:00 2001 From: Dmitriy Ryajov Date: Tue, 29 May 2018 13:33:21 -0600 Subject: [PATCH 07/11] chore: rename and add description --- package.json | 4 ++-- src/index.js | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index bc9746c..a180870 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "js-libp2p-nat-mngr", + "name": "libp2p-nat-mngr", "version": "1.0.0", - "description": "", + "description": "Create and remove NAT port mappings.", "main": "src/index.js", "scripts": { "lint": "aegir lint", diff --git a/src/index.js b/src/index.js index 9b302c1..f4d8336 100644 --- a/src/index.js +++ b/src/index.js @@ -20,12 +20,12 @@ class NatManager extends EE { this.activeMappings = {} } - addMapping (intPort, extPort, lifetime, callback) { + addMapping (intPort, extPort, ttl, callback) { tryEach(this.mappers.map((mapper) => { return (cb) => { return mapper.addMapping(intPort, extPort, - lifetime, + ttl, (err, mapping) => { if (err) { return callback(err) @@ -58,6 +58,10 @@ class NatManager extends EE { ], callback) } + getPublicIp (callback) { + network.get_public_ip(callback) + } + close (callback) { parallel(Object.keys(this.activeMappings).map((key) => { const [ip, port] = key.split(':') From ecaefb0107fd8e256b5123ef19c12fd87c6d0377 Mon Sep 17 00:00:00 2001 From: Dmitriy Ryajov Date: Thu, 31 May 2018 16:27:19 -0600 Subject: [PATCH 08/11] fix: change repeated to optional --- package.json | 4 ++- requirements.md | 3 +- src/index.js | 12 +++++--- src/mappers/mapper.js | 68 ------------------------------------------- src/mappers/pmp.js | 6 ++-- src/mappers/upnp.js | 6 ++-- test/node.js | 1 + 7 files changed, 19 insertions(+), 81 deletions(-) delete mode 100644 src/mappers/mapper.js diff --git a/package.json b/package.json index a180870..16d7a95 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "author": "", "license": "ISC", "dependencies": { + "chai-checkmark": "^1.0.1", "dgram": "^1.0.1", "ipaddr.js": "^1.7.0", "nat-pmp": "^1.0.0", "nat-upnp": "^1.1.1", - "network": "^0.4.1" + "network": "^0.4.1", + "sinon": "^5.0.10" }, "devDependencies": { "aegir": "^13.1.0", diff --git a/requirements.md b/requirements.md index 4d3e281..e680330 100644 --- a/requirements.md +++ b/requirements.md @@ -1,7 +1,6 @@ # Requirements -- mapper should be able to hold multiple mappings for the same external - port but different external ips +- mapper should be able to hold multiple mappings for the same external port but different external ips - for example, if the client moves around (laptops, mobile devices) and connects from different access points, the mapper should be able to detect if we're using a different external ip/getaway for which we don't have a prior mapping and add one diff --git a/src/index.js b/src/index.js index f4d8336..2e34965 100644 --- a/src/index.js +++ b/src/index.js @@ -28,15 +28,15 @@ class NatManager extends EE { ttl, (err, mapping) => { if (err) { - return callback(err) + return cb(err) } const mapKey = `${mapping.externalIp}:${mapping.externalPort}` this.activeMappings[mapKey] = mapper - callback(null, mapping) + cb(null, mapping) }) } - })) + }), callback) } deleteMapping (extPort, extIp, callback) { @@ -52,7 +52,7 @@ class NatManager extends EE { (ip, cb) => { const mapper = this.activeMappings[`${ip}:${extPort}`] if (mapper) { - mapper.deleteMapping(extPort, callback) + mapper.deleteMapping(extPort, cb) } } ], callback) @@ -62,6 +62,10 @@ class NatManager extends EE { network.get_public_ip(callback) } + getGwIp (callback) { + network.get_gateway_ip(callback) + } + close (callback) { parallel(Object.keys(this.activeMappings).map((key) => { const [ip, port] = key.split(':') diff --git a/src/mappers/mapper.js b/src/mappers/mapper.js deleted file mode 100644 index 28232fe..0000000 --- a/src/mappers/mapper.js +++ /dev/null @@ -1,68 +0,0 @@ -'use strict' -const debug = require('debug') - -class BaseMapper { - constructor (name, port) { - this.name = name - this.port = port - this.mappings = {} - - this.log = debug(`nat-puncher:${name}`) - this.log.err = debug(`nat-puncher:${name}:error`) - } - - newMapping (port) { - return { - routerIp: null, - internalIp: null, - internalPort: port, - externalIp: null, // Only provided by PCP, undefined for other protocols - externalPort: null, // The actual external port of the mapping, -1 on failure - ttl: null, // The actual (response) lifetime of the mapping - protocol: this.name, // The protocol used to make the mapping ('natPmp', 'pcp', 'upnp') - nonce: null, // Only for PCP; the nonce field for deletion - errInfo: null // Error message if failure; currently used only for UPnP - } - } - - addMapping (intPort, extPort, ttl, callback) { - // If lifetime is zero, we want to refresh every 24 hours - ttl = !ttl ? 24 * 60 * 60 : ttl - - this.createMapping(intPort, - extPort, - ttl, - (err, mapping) => { - if (err) { - this.log.err(err) - return callback(err) - } - this.mappings[`${mapping.externalIp}:${mapping.externalPort}`] = mapping - callback(null, mapping) - }) - } - - createMapping (intPort, extPort, lifetime, cb) { - cb(new Error('Not implemented!')) - } - - deleteMapping (mapping, callback) { - this._internalDeleteMapping(mapping.internalPort, - mapping.externalPort, - (err) => { - if (err) { - return callback(err) - } - - // delete the mappings - delete this.mappings[`${mapping.externalIp}:${mapping.externalPort}`] - callback() - }) - } - - _internalDeleteMapping (intPort, extPort, callback) { - callback(new Error('Not implemented!')) - } -} - -module.exports = BaseMapper diff --git a/src/mappers/pmp.js b/src/mappers/pmp.js index 4812f00..98477a1 100644 --- a/src/mappers/pmp.js +++ b/src/mappers/pmp.js @@ -4,7 +4,7 @@ const natPmp = require('nat-pmp') const waterfall = require('async/waterfall') const network = require('network') -const Mapper = require('./mapper') +const Mapper = require('./') class NatPMP extends Mapper { constructor () { @@ -20,7 +20,7 @@ class NatPMP extends Mapper { * @param {Function} callback * @returns {undefined} */ - createMapping (intPort, extPort, ttl, callback) { + _addPortMapping (intPort, extPort, ttl, callback) { network.get_active_interface((err, activeIf) => { if (err) { return callback(err) @@ -65,7 +65,7 @@ class NatPMP extends Mapper { }) } - _internalDeleteMapping (intPort, extPort, callback) { + _removePortMapping (intPort, extPort, callback) { network.get_gateway_ip((err, routerIp) => { if (err) { return callback(err) diff --git a/src/mappers/upnp.js b/src/mappers/upnp.js index bd01c40..fede667 100644 --- a/src/mappers/upnp.js +++ b/src/mappers/upnp.js @@ -4,7 +4,7 @@ const natUpnp = require('nat-upnp') const waterfall = require('async/waterfall') const network = require('network') -const Mapper = require('./mapper') +const Mapper = require('./') class NatPMP extends Mapper { constructor () { @@ -20,7 +20,7 @@ class NatPMP extends Mapper { * @param {Function} callback * @returns {undefined} */ - createMapping (intPort, extPort, ttl, callback) { + _addPortMapping (intPort, extPort, ttl, callback) { network.get_active_interface((err, activeIf) => { if (err) { return callback(err) @@ -64,7 +64,7 @@ class NatPMP extends Mapper { }) } - _internalDeleteMapping (intPort, extPort, callback) { + _removePortMapping (intPort, extPort, callback) { const client = natUpnp.createClient() client.portUnmapping({ public: extPort diff --git a/test/node.js b/test/node.js index d4f211b..ac2f3b4 100644 --- a/test/node.js +++ b/test/node.js @@ -1,4 +1,5 @@ 'use strict' +require('./manager') require('./pmp') require('./upnp') From f7635b0a517adde045ff244aa0bfed76624fd7dd Mon Sep 17 00:00:00 2001 From: Dmitriy Ryajov Date: Tue, 7 Aug 2018 21:40:28 -0600 Subject: [PATCH 09/11] wip --- .gitignore | 1 + src/mappers/index.js | 67 ++++++++++++++++++++++++++++++++++++++++++++ test/manager.js | 60 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/mappers/index.js create mode 100644 test/manager.js diff --git a/.gitignore b/.gitignore index f0695f1..1f1d1ce 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ test/repo-tests* **/bundle.js **/.nyc_output +**/.vscode/ # Logs logs diff --git a/src/mappers/index.js b/src/mappers/index.js new file mode 100644 index 0000000..a4f9295 --- /dev/null +++ b/src/mappers/index.js @@ -0,0 +1,67 @@ +'use strict' +const debug = require('debug') + +class BaseMapper { + constructor (name) { + this.name = name + this.mappings = {} + + this.log = debug(`nat-puncher:${name}`) + this.log.err = debug(`nat-puncher:${name}:error`) + } + + newMapping (port) { + return { + routerIp: null, + internalIp: null, + internalPort: port, + externalIp: null, // Only provided by PCP, undefined for other protocols + externalPort: null, // The actual external port of the mapping, -1 on failure + ttl: null, // The actual (response) lifetime of the mapping + protocol: this.name, // The protocol used to make the mapping ('natPmp', 'pcp', 'upnp') + nonce: null, // Only for PCP; the nonce field for deletion + errInfo: null // Error message if failure; currently used only for UPnP + } + } + + addMapping (intPort, extPort, ttl, callback) { + // If lifetime is zero, we want to refresh every 24 hours + ttl = !ttl ? 24 * 60 * 60 : ttl + + this._addPortMapping(intPort, + extPort, + ttl, + (err, mapping) => { + if (err) { + this.log.err(err) + return callback(err) + } + this.mappings[`${mapping.externalIp}:${mapping.externalPort}`] = mapping + callback(null, mapping) + }) + } + + _addPortMapping (intPort, extPort, lifetime, cb) { + cb(new Error('Not implemented!')) + } + + deleteMapping (mapping, callback) { + this._removePortMapping(mapping.internalPort, + mapping.externalPort, + (err) => { + if (err) { + return callback(err) + } + + // delete the mappings + delete this.mappings[`${mapping.externalIp}:${mapping.externalPort}`] + callback() + }) + } + + _removePortMapping (intPort, extPort, callback) { + callback(new Error('Not implemented!')) + } +} + +module.exports = BaseMapper diff --git a/test/manager.js b/test/manager.js new file mode 100644 index 0000000..ec40e78 --- /dev/null +++ b/test/manager.js @@ -0,0 +1,60 @@ +/* eslint-env mocha */ +'use strict' + +const chai = require('chai') +chai.use(require('dirty-chai')) +chai.use(require('chai-checkmark')) + +const Manager = require('../src') +const Mapper = require('../src/mappers') + +const expect = chai.expect + +describe('NAT manager', () => { + it('should create mappings', (done) => { + class Mapper1 extends Mapper { + constructor () { + super('mapper1') + } + + _addPortMapping (intPort, extPort, lifetime, cb) { + cb(null, this.newMapping(intPort)) + } + } + + const manager = new Manager([ + new Mapper1() + ]) + + manager.addMapping(55555, 55555, 0, (err, mapping) => { + expect(err).to.not.exist() + expect(mapping).to.exist() + expect(mapping.internalPort).to.eql(55555) + done() + }) + }) + + it('should try mappings in order', (done) => { + let fail = true + + class Mapper1 extends Mapper { + _addPortMapping (intPort, extPort, lifetime, cb) { + cb(fail ? new Error('fail') : null, this.newMapping(intPort)) + fail = false + } + } + + const manager = new Manager([ + new Mapper1('1'), + new Mapper1('2') + ]) + + manager.addMapping(55555, 55555, 0, (err, mapping) => { + expect(err).to.not.exist() + expect(mapping).to.exist() + expect(mapping.protocol).to.eql('2') + expect(mapping.internalPort).to.eql(55555) + done() + }) + }) +}) From 43e98910a5e9d9454c275e4cb7c63e1b84a9300e Mon Sep 17 00:00:00 2001 From: Dmitriy Ryajov Date: Tue, 7 Aug 2018 21:47:03 -0600 Subject: [PATCH 10/11] wip --- package.json | 2 +- test/pmp.js | 4 ++-- test/upnp.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 16d7a95..36da577 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "coverage-publish": "aegir coverage -u" }, "author": "", - "license": "ISC", + "license": "MIT", "dependencies": { "chai-checkmark": "^1.0.1", "dgram": "^1.0.1", diff --git a/test/pmp.js b/test/pmp.js index ef084fb..938faa8 100644 --- a/test/pmp.js +++ b/test/pmp.js @@ -26,12 +26,12 @@ describe('NAT-PMP tests', () => { natmapping = mapping done() }) - }).timeout(5 * 10000) + }) it('should delete a mapping', (done) => { natPMP.deleteMapping(natmapping, (error) => { expect(error).to.not.exist() done() }) - }).timeout(5 * 10000) + }) }) diff --git a/test/upnp.js b/test/upnp.js index efc7b71..46331bc 100644 --- a/test/upnp.js +++ b/test/upnp.js @@ -26,12 +26,12 @@ describe('Nat UpNP tests', () => { natmapping = mapping done() }) - }).timeout(5 * 10000) + }) it('should delete a mapping', (done) => { natUpnp.deleteMapping(natmapping, (error) => { expect(error).to.not.exist() done() }) - }).timeout(5 * 10000) + }) }) From 60a87f21488d0366cbc5e4fe80a8072343982357 Mon Sep 17 00:00:00 2001 From: Dmitriy Ryajov Date: Wed, 8 Aug 2018 04:11:24 -0600 Subject: [PATCH 11/11] feat: add autorenew functionality --- src/index.js | 42 +++++++++++++++++++++++++++++++++++++++++- test/manager.js | 29 +++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 2e34965..062b37f 100644 --- a/src/index.js +++ b/src/index.js @@ -4,20 +4,59 @@ const NatPmp = require('./mappers/pmp') const UPnP = require('./mappers/upnp') const EE = require('events') const tryEach = require('async/tryEach') +const eachSeries = require('async/eachSeries') const parallel = require('async/parallel') const waterfall = require('async/waterfall') const network = require('network') +const log = require('debug')('libp2p-nat-mngr') + class NatManager extends EE { - constructor (mappers) { + constructor (mappers, options) { super() + options = options || { + autorenew: true, + every: 60 * 10 * 1000 + } + this.mappers = mappers || [ new NatPmp(), new UPnP() ] this.activeMappings = {} + + if (options.autorenew) { + setInterval(() => { + this.renewMappings() + }, options.every) + } + } + + renewMappings (callback) { + callback = callback || (() => {}) + this.getPublicIp((err, ip) => { + if (err) { + return log(err) + } + + eachSeries(Object.keys(this.activeMappings), (key, cb) => { + const mapping = this.activeMappings[key].mappings[key] + if (mapping.externalIp !== ip) { + delete this.activeMappings[key] + this.addMapping(mapping.internalPort, + mapping.externalPort, + mapping.ttl, + (err) => { + if (err) { + return log(err) + } + return cb() + }) + } + }, callback) + }) } addMapping (intPort, extPort, ttl, callback) { @@ -33,6 +72,7 @@ class NatManager extends EE { const mapKey = `${mapping.externalIp}:${mapping.externalPort}` this.activeMappings[mapKey] = mapper + this.emit('mapping', mapping) cb(null, mapping) }) } diff --git a/test/manager.js b/test/manager.js index ec40e78..9f51ee6 100644 --- a/test/manager.js +++ b/test/manager.js @@ -17,7 +17,7 @@ describe('NAT manager', () => { super('mapper1') } - _addPortMapping (intPort, extPort, lifetime, cb) { + _addPortMapping (intPort, extPort, ttl, cb) { cb(null, this.newMapping(intPort)) } } @@ -38,7 +38,7 @@ describe('NAT manager', () => { let fail = true class Mapper1 extends Mapper { - _addPortMapping (intPort, extPort, lifetime, cb) { + _addPortMapping (intPort, extPort, ttl, cb) { cb(fail ? new Error('fail') : null, this.newMapping(intPort)) fail = false } @@ -57,4 +57,29 @@ describe('NAT manager', () => { done() }) }) + + it('should renew mapping', (done) => { + class Mapper1 extends Mapper { + _addPortMapping (intPort, extPort, ttl, cb) { + const mapping = this.newMapping(intPort) + mapping.externalIp = '127.0.0.1' + mapping.externalPort = intPort + cb(null, mapping) + } + } + + const manager = new Manager([ + new Mapper1('1') + ]) + + manager.addMapping(55555, 55555, 0, (err, mapping) => { + manager.renewMappings(() => { + expect(err).to.not.exist() + expect(mapping).to.exist() + expect(mapping.protocol).to.eql('1') + expect(mapping.internalPort).to.eql(55555) + done() + }) + }) + }) })