Skip to content

Instantly share code, notes, and snippets.

@lethean
Created May 8, 2015 02:57
Show Gist options
  • Save lethean/b569d61eba1f60e3243d to your computer and use it in GitHub Desktop.
Save lethean/b569d61eba1f60e3243d to your computer and use it in GitHub Desktop.
Node.js UPnP Port Mapping
/* -*- 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