Created
May 8, 2015 02:57
-
-
Save lethean/b569d61eba1f60e3243d to your computer and use it in GitHub Desktop.
Node.js UPnP Port Mapping
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* -*- mode: javascript; js-indent-level: 2; tab-width: 2; indent-tabs-mode: nil; -*- | |
vim: set autoindent expandtab shiftwidth=2 softtabstop=2 tabstop=2: */ | |
'use strict'; | |
var dgram = require('dgram'), | |
http = require('http'), | |
url = require('url'), | |
xml2js = require('xml2js'), | |
Buffer = require('buffer').Buffer; | |
function UpnpPortMapping() { | |
var self = this; | |
self.controlURL = null; //'http://192.168.0.1:4895/etc/linuxigd/gateconnSCPD.ctl'; | |
self.serviceType = null; //'urn:schemas-upnp-org:service:WANIPConnection:1>'; | |
} | |
UpnpPortMapping.prototype.search = function (callback) { | |
var SSDP_ADDR = '239.255.255.250', | |
SSDP_PORT = 1900, | |
SSDP_MX = 2, | |
SSDP_ST = 'urn:schemas-upnp-org:device:InternetGatewayDevice:1', | |
ssdpRequest = 'M-SEARCH * HTTP/1.1\r\n' + | |
'HOST: ' + SSDP_ADDR + ':' + SSDP_PORT + '\r\n' + | |
'MAN: "ssdp:discover"\r\n' + | |
'MX: ' + SSDP_MX + '\r\n' + | |
'ST: ' + SSDP_ST + '\r\n\r\n'; | |
var self = this, | |
sock, | |
timeout; | |
if (self.controlURL) { | |
return callback(self.controlURL); | |
} | |
function finalize(controlURL, serviceType) { | |
self.controlURL = controlURL; | |
self.serviceType = serviceType; | |
if (timeout) | |
clearTimeout(timeout); | |
timeout = null; | |
sock.close(); | |
return callback(controlURL); | |
} | |
timeout = setTimeout(function () { | |
timeout = null; | |
return finalize(null, null); | |
}, 1000); | |
sock = dgram.createSocket('udp4'); | |
sock.on('message', function (message, rinfo) { | |
var msg, | |
location, | |
idx; | |
if (!timeout) | |
return; | |
msg = message.toString(); | |
// Ignore messages from non IGD devices. | |
if (msg.indexOf(SSDP_ST) < 0) | |
return; | |
msg.split('\r\n').forEach(function (line) { | |
var words; | |
words = line.split(': '); | |
if (words[0].toUpperCase() !== 'LOCATION') | |
return; | |
location = words[1]; | |
}); | |
if (!location) | |
return; | |
http.get(location, function (res) { | |
var locationXml; | |
if (!timeout) | |
return; | |
locationXml = ''; | |
res.on('data', function (chunk) { | |
locationXml += chunk; | |
}); | |
res.on('end', function () { | |
var parseOptions = { | |
trim: true, | |
explicitRoot: false, | |
explicitArray: false | |
}; | |
xml2js.parseString(locationXml, parseOptions, function (err, result) { | |
searchNodes(result, 'service').forEach(function (service) { | |
var serviceType, u; | |
if (!service.serviceType) | |
return; | |
// Check exact service type. | |
serviceType = service.serviceType; | |
if (serviceType.indexOf('WANIPConnection') >= 0 || | |
serviceType.indexOf('WANPPPConnection') >= 0) { | |
u = url.parse(location); | |
return finalize(u.protocol + '//' + u.host + service.controlURL, serviceType); | |
} | |
}); | |
function searchNodes(object, name) { | |
var children = []; | |
searchNode(object); | |
function searchNode(obj) { | |
Object.keys(obj).forEach(function (key) { | |
var o = obj[key]; | |
if (key == name) | |
children.push(o); | |
if (typeof o === 'object') | |
searchNode(o); | |
}); | |
} | |
return children; | |
} | |
}); | |
}); | |
}); | |
}); | |
sock.send(ssdpRequest, 0, ssdpRequest.length, SSDP_PORT, SSDP_ADDR); | |
}; | |
UpnpPortMapping.prototype.execute = function (action, args, callback) { | |
var self = this, | |
data, req, u; | |
if (self.controlURL) | |
requestSoap(); | |
else | |
self.search(requestSoap); | |
function requestSoap() { | |
if (!self.controlURL) { | |
return callback(Error('No IGD Control URL')); | |
} | |
data = '<?xml version="1.0"?>\r\n' + | |
'<s:Envelope \r\n' + | |
' xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"\r\n' + | |
' s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">\r\n' + | |
'<s:Body>\r\n' + | |
' <u:' + action + ' xmlns:u="' + self.serviceType + '">\r\n' + | |
args.map(function(args) { | |
return ' <' + args[0]+ '>' + | |
(args[1] === undefined ? '' : args[1]) + | |
'</' + args[0] + '>\r\n'; | |
}).join('') + | |
' </u:' + action + '>\r\n' + | |
'</s:Body>\r\n' + | |
'</s:Envelope>\r\n'; | |
u = url.parse(self.controlURL); | |
req = http.request({ | |
hostname: u.hostname, | |
port: u.port, | |
path: u.path, | |
method: 'POST', | |
headers: { | |
'Content-Type': 'text/xml', | |
'Content-Length': Buffer.byteLength(data), | |
'SOAPAction': self.serviceType + '#' + action | |
}, | |
}, function(res) { | |
var response; | |
response = ''; | |
res.on('data', function (chunk) { | |
response += chunk; | |
}); | |
res.on('end', function () { | |
var parseOptions = { | |
trim: true, | |
explicitRoot: false, | |
explicitArray: false | |
}; | |
xml2js.parseString(response, parseOptions, function(err, result) { | |
var obj, ns, body, fault; | |
ns = getNamespace(result, 'http://schemas.xmlsoap.org/soap/envelope/'); | |
body = result[ns + 'Body']; | |
if (res.statusCode !== 200) { | |
fault = body[ns + 'Fault']; | |
err = Error('Request failed: ' + | |
'httpCode:' + res.statusCode + ' ' + | |
'fault:' + fault.faultcode + | |
'(' + fault.faultstring + ') ' + | |
'error:' + fault.detail.UPnPError.errorCode + | |
'(' + fault.detail.UPnPError.errorDescription + ') '); | |
err.httpCode = res.statusCode; | |
err.fault = {}; | |
err.fault.code = fault.faultcode; | |
err.fault.message = fault.faultstring; | |
err.upnp = {}; | |
err.upnp.code = fault.detail.UPnPError.errorCode; | |
err.upnp.message = fault.detail.UPnPError.errorDescription; | |
} | |
callback(err, body); | |
}); | |
}); | |
}); | |
req.on('error', function (err) { | |
return callback(err); | |
}); | |
req.write(data); | |
req.end(); | |
function getNamespace(obj, uri) { | |
var ns; | |
if (obj.$) { | |
Object.keys(obj.$).some(function(key) { | |
if (!/^xmlns:/.test(key)) | |
return; | |
if (obj.$[key] !== uri) | |
return; | |
ns = key.replace(/^xmlns:/, ''); | |
return true; | |
}); | |
} | |
return ns ? ns + ':' : ''; | |
} | |
} | |
}; | |
UpnpPortMapping.prototype.getExternalIP = function (callback) { | |
var self = this; | |
self.execute('GetExternalIPAddress', [], function (err, data) { | |
var key; | |
if (err) | |
return callback(err, null); | |
Object.keys(data).some(function(k) { | |
if (!/:GetExternalIPAddressResponse$/.test(k)) | |
return false; | |
key = k; | |
return true; | |
}); | |
if (!key) | |
return callback(Error('Incorrect response'), null); | |
return callback(null, data[key].NewExternalIPAddress); | |
}); | |
}; | |
UpnpPortMapping.prototype.list = function (callback) { | |
var self = this, | |
entries, idx; | |
entries = []; | |
idx = 0; | |
getGenericPortMappingEntry(); | |
function getGenericPortMappingEntry() { | |
self.execute('GetGenericPortMappingEntry', [ | |
[ 'NewPortMappingIndex', idx++ ] | |
], function (err, data) { | |
var key; | |
if (err) | |
return callback(null, entries); | |
Object.keys(data).some(function(k) { | |
if (!/:GetGenericPortMappingEntryResponse$/.test(k)) | |
return false; | |
key = k; | |
return true; | |
}); | |
if (!key) | |
return; | |
entries.push(data[key]); | |
getGenericPortMappingEntry(); | |
}); | |
} | |
}; | |
UpnpPortMapping.prototype.add = function (opts, callback) { | |
var self = this; | |
self.execute('AddPortMapping', [ | |
[ 'NewRemoteHost', '' ], | |
[ 'NewExternalPort', opts.externalPort ], | |
[ 'NewProtocol', opts.protocol ? opts.protocol.toUpperCase() : 'TCP' ], | |
[ 'NewInternalPort', opts.internalPort ], | |
[ 'NewInternalClient', opts.internalClient ], | |
[ 'NewEnabled', 1 ], | |
[ 'NewPortMappingDescription', opts.description || 'node:upnp:port' ], | |
[ 'NewLeaseDuration', 0 ] | |
], function (err, data) { | |
if (callback) | |
return callback(err); | |
}); | |
}; | |
UpnpPortMapping.prototype.remove = function (opts, callback) { | |
var self = this; | |
self.execute('DeletePortMapping', [ | |
[ 'NewRemoteHost', '' ], | |
[ 'NewExternalPort', opts.externalPort ], | |
[ 'NewProtocol', opts.protocol ? opts.protocol.toUpperCase() : 'TCP' ], | |
], function (err, data) { | |
if (callback) | |
return callback(err); | |
}); | |
}; | |
UpnpPortMapping.prototype.unmapPort = function (entry, callback) { | |
var self = this; | |
// Fetch the port mapping list to find mapped entry. | |
self.list(function (err, entries) { | |
var removed; | |
if (err) | |
return callback(err); | |
removed = entries.filter(function (e) { | |
if (e.NewInternalClient != entry.host) | |
return false; | |
if (entry.port && e.NewInternalPort != entry.port) | |
return false; | |
if (entry.protocol && e.NewProtocol != entry.protocol.toUpperCase()) | |
return false; | |
return true; | |
}); | |
removeOneHost(); | |
function removeOneHost() { | |
var entry = removed.shift(); | |
if (!entry) | |
return callback(null); | |
self.remove({ | |
externalPort: entry.NewExternalPort, | |
protocol: entry.NewProtocol | |
}, function (err) { | |
removeOneHost(); | |
}); | |
} | |
}); | |
}; | |
UpnpPortMapping.prototype.mapPort = function (entry, callback) { | |
var self = this; | |
// Fetch the port mapping list to check available ports. | |
self.list(function (err, entries) { | |
var usedPorts; | |
if (err) | |
return callback(err, entry); | |
// If there is the same entry, avoid duplicate work. | |
if (entries.some(function (e) { | |
if (e.NewInternalClient == entry.host && | |
e.NewInternalPort == entry.port && | |
e.NewProtocol == entry.protocol) { | |
entry.externalPort = e.NewExternalPort; | |
return true; | |
} | |
return false; | |
})) | |
return callback(null, entry); | |
usedPorts = {}; | |
entries.forEach(function (e) { | |
usedPorts[e.NewExternalPort] = true; | |
}); | |
function findEmptyPort(port) { | |
var p = port; | |
while (usedPorts[p]) { | |
p++; | |
if (p > 65535) | |
p = 1; | |
if (p == port) | |
return 0; | |
} | |
usedPorts[p] = true; | |
return p; | |
} | |
entry.externalPort = findEmptyPort(entry.port); | |
if (entry.externalPort <= 0) | |
return callback(Error('no available external port'), entry); | |
self.add({ | |
protocol: entry.protocol ? entry.protocol.toUpperCase() : 'TCP', | |
externalPort: entry.externalPort, | |
internalPort: entry.port, | |
internalClient: entry.host, | |
description: entry.description | |
}, function (err) { | |
if (err && err.upnp && err.upnp.code == 718) { | |
// Retry again in case of conflict in mapping entries | |
return self.mapPort(entry, callback); | |
} | |
return callback(err, entry); | |
}); | |
}); | |
}; | |
module.exports = new UpnpPortMapping(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment