Compare commits

..

20 Commits

Author SHA1 Message Date
AJ ONeal
f6b7c89926 features, fixes, and bump 2015-12-31 01:03:36 -05:00
AJ ONeal
69ea7fa7c1 scrap init files 2015-12-31 05:43:12 +00:00
AJ ONeal
aaebc5494e demo 2015-12-31 02:37:57 +00:00
AJ ONeal
bef1fbaba4 working better 2015-12-31 02:21:29 +00:00
AJ ONeal
d11a51ff90 more detail 2015-12-31 00:27:48 +00:00
AJ ONeal
9de7d807d2 incremental update 2015-12-30 23:13:06 +00:00
AJ ONeal
a220461b44 note non-root port binding for node.js 2015-12-30 22:35:17 +00:00
AJ ONeal
6cc9ec30f7 example systemd service 2015-12-30 21:53:38 +00:00
AJ ONeal
967a4a99fc standalone upnp 2015-12-30 21:42:23 +00:00
AJ ONeal
6382701c91 tested working upnp and pmp 2015-12-30 21:40:52 +00:00
AJ ONeal
62509a4800 minor bugfixes, log enchancements, etc 2015-12-30 11:48:33 -05:00
e8e23388d0 move cruft 2015-12-30 08:58:21 +00:00
8f6a5f58ac remove cruft 2015-12-30 08:53:12 +00:00
4f12286b55 pass nat-pmp 2015-12-30 08:46:22 +00:00
8e5accc6ce mostly works 2015-12-30 08:22:04 +00:00
4bc1adf7a2 add deps 2015-12-30 07:39:21 +00:00
5b1191f1be lots and lots of failover 2015-12-30 06:35:21 +00:00
9bb093fd4f use internal port 2015-12-30 03:42:44 +00:00
6ecb868ae1 test loopback listener 2015-12-30 03:36:14 +00:00
0a3b457583 get all external ips 2015-12-30 02:47:38 +00:00
21 changed files with 929 additions and 386 deletions

138
README.md
View File

@ -1,12 +1,140 @@
# holepunch
A node.js library and cli for using UPnP SSDP
and ZeroConf (Bonjour) NAT-PMP
to make home and office devices and services Internet-accessible.
A commandline tool (cli) and library (node.js api) for making devices
in your home and office Internet-accessible.
## Progress
Uses UPnP / SSDP and NAT-PMP / ZeroConf (Bonjour) for port forwarding / port mapping.
in development
Works for IPv4 and IPv6 interfaces.
## Status
Published as alpha, but nearing release quality.
```bash
git clone git@github.com:Daplie/holepunch.git
pushd holepunch
node bin/holepunch.js --debug
```
## Install
**Commandline Tool**
```bash
npm install --global holepunch
```
**node.js Library**
```
npm install --save holepunch
```
## Examples
Some examples that work with what's currently published.
### Commandline (CLI)
```bash
holepunch --help
```
```
holepunch --plain-ports 80,65080 --tls-ports 443,65443
```
### API
This is the current Dec 30th api in master
```javascript
var punch = require('holepunch');
punch({
debug: true
, mappings: [{ internal: 443, external: 443, secure: true }]
, ipifyUrls: ['api.ipify.org'],
, protocols: ['none', 'upnp', 'pmp']
, rvpnConfigs: []
}).then(function (mappings) {
// be sure to check for an `error` attribute on each mapping
console.log(mappings);
}, function (err) {
console.log(err);
});
```
## API (v1.0.0 draft)
TODO: This is the api that I think I'd like to use for the solid v1.0.0
```javascript
punch(opts)
opts.debug = true | false // print extra debug info
opts.mappings = [ // these ports will be tested via tcp / http
{ internal: 80 // the port which is bound locally
, external: 80 // the port as it is exposed on the internet
, loopback: true | false // whether or not to attempt an http(s) loopback test
, secure: true | false // (default: true) whether to use tls or plaintext
}
]
opts.ipifyUrls = [ // ipify urls
'api.ipify.org' // default
]
opts.upnp = true | false // attempt mapping via nat-upnp
opts.pmp = true | false // attempt mapping via nat-pmp
opts.rvpnConfigs = [
'/etc/holepunch/rvpn.json' // TODO (not implemented)
]
```
## Commandline
TODO `--prebound-ports 22`
```
Usage:
holepunch.js [OPTIONS] [ARGS]
Options:
--debug BOOLEAN show traces and logs
--plain-ports STRING Port numbers to test with plaintext loopback.
(default: 65080)
(formats: <port>,<internal:external>)
--tls-ports STRING Port numbers to test with tls loopback.
(default: null)
--ipify-urls STRING Comma separated list of URLs to test for external ip.
(default: api.ipify.org)
--protocols STRING Comma separated list of ip mapping protocols.
(default: none,upnp,pmp)
--rvpn-configs STRING Comma separated list of Reverse VPN config files in
the order they should be tried. (default: null)
-h, --help Display help and usage details
```
## Non-Root
You **do not need root** to map ports, but you may need root to test them.
If you're cool with allowing all node programs to bind to privileged ports, try this:
```bash
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/node
```
# License

View File

@ -1,83 +0,0 @@
'use strict';
var PromiseA = require('bluebird').Promise;
var updateIp = require('./helpers/update-ip.js').update;
var request = PromiseA.promisifyAll(require('request'));
var requestAsync = PromiseA.promisify(require('request'));
var upnpForward = require('./helpers/upnp-forward').upnpForward;
var pmpForward = require('./helpers/pmp-forward').pmpForward;
var loopbackHttps = require('./loopback-https');
//var checkip = require('check-ip-address');
function openPort(ip, port) {
if (!/tcp|https|http/.test(port.protocol || 'tcp')) {
throw new Error('not yet supported \'' + port.protocol + '\'');
}
if (false === port.testable) {
return PromiseA.resolve();
}
return loopbackHttps.create(ip, port.private, port.public).then(function () {
console.log('success');
}).catch(function (err) {
// TODO test err
return upnpForward(port).catch(function (err) {
console.error('[ERROR] UPnP Port Forward');
console.error(err);
// TODO test err
return pmpForward(port);
}).then(function () {
return loopbackHttps.create(ip, port.private, port.public);
});
});
}
// 1. update dyndns
// 1.5. check ip every 5 min
// 2. loopback test on ip for http / https / ssh
// 3. if needed: discover gateway, map ports
function beacon(hostnames, ports) {
// test with
// dig -p 53 @redirect-www.org pi.nadal.daplie.com A
return updateIp({
updater: 'redirect-www.org'
, port: 65443
, ddns: hostnames.map(function (hostname) {
return { "name": hostname /*, "value": ipaddress, "type": "A"*/ };
})
}).then(function (data) {
var promises = [];
console.log("Updated DynDNS");
console.log(data);
ports.forEach(function (port) {
promises.push(openPort(JSON.parse(data)[0].answers[0] || hostname, port));
});
return PromiseA.all(promises);
}).then(function () {
console.log('opened ports');
});
/*
request.getAsync('http://checkip.hellabit.com').spread(function (resp, data) {
console.log("External IP is", data);
}).then(function () {
return upnpForward().catch(function (err) {
console.error('ERROR: UPnP failure:');
console.error(err);
});
}).then(function () {
return pmpForward().catch(function (err) {
console.error('TODO: Notify user that their router is not compatible');
});
});
// TODO test roundtrip
*/
}
//setInterval(beacon, 5 * 60 * 1000);
exports.run = beacon;

View File

@ -8,10 +8,13 @@ var cli = require('cli');
// TODO txt records for browser plugin: TXT _http.example.com _https.example.com
cli.parse({
debug: [ false, " show traces and logs", 'boolean', false ]
, 'plain-ports': [ false, " Port numbers to test with plaintext loopback. (default: 65080) (formats: <port>,<internal:external>,<internal:external1|external2>)", 'string' ]
, 'plain-ports': [ false, " Port numbers to test with plaintext loopback. (default: 65080) (formats: <port>,<internal:external>)", 'string' ]
//, 'plain-ports': [ false, " Port numbers to test with plaintext loopback. (default: 65080) (formats: <port>,<internal:external>,<internal:external1|external2>)", 'string' ]
, 'tls-ports': [ false, " Port numbers to test with tls loopback. (default: null)", 'string' ]
, 'ipify-urls': [ false, " Comma separated list of URLs to test for external ip. (default: api.ipify.org)", 'string' ]
, 'protocols': [ false, " Comma separated list of ip mapping protocols. (default: none,upnp,pmp)", 'string' ]
, protocols: [ false, " Comma separated list of ip mapping protocols. (default: none,upnp,pmp)", 'string' ]
//, upnp: [ false, " Use nat-upnp. (default: true)", 'boolean' ]
//, pmp: [ false, " Use nat-pmp. (default: true)", 'boolean' ]
, 'rvpn-configs': [ false, " Comma separated list of Reverse VPN config files in the order they should be tried. (default: null)", 'string' ]
// TODO allow standalone, webroot, etc
});
@ -21,6 +24,11 @@ cli.main(function(_, options) {
console.log('');
var args = {};
var hp = require('../');
var loopback = require('../lib/loopback-listener');
var plainPorts = options['plain-ports'];
var tlsPorts = options['tls-ports'];
var pretest;
var protocols;
function parsePorts(portstr) {
var parts = portstr.split(':');
@ -28,7 +36,7 @@ cli.main(function(_, options) {
internal: parseInt(parts[0], 10)
, external: (parts[1]||parts[0]).split('|').map(function (port) {
return parseInt(port, 10);
})
})[0]
};
return opts;
@ -39,14 +47,12 @@ cli.main(function(_, options) {
}
if (options.debug) {
console.log('options');
console.log('[HP CLI] options');
console.log(options);
}
args.debug = options.debug;
args.plainPorts = options['plain-ports'];
args.tlsPorts = options['tls-ports'];
args.protocols = options.protocols;
protocols = options.protocols;
args.ipifyUrls = options['ipify-urls'];
args.rvpnConfigs = options['rvpn-configs'];
@ -55,21 +61,46 @@ cli.main(function(_, options) {
} else {
args.ipifyUrls = (args.ipifyUrls || 'api.ipify.org').split(',');
}
if ('false' === args.protocols || false === args.protocols) {
args.protocols = [];
if ('false' === protocols || false === protocols) {
protocols = [];
} else {
args.protocols = (args.protocols || 'none,upnp,pmp').split(',');
protocols = (protocols || 'none,upnp,pmp').split(',');
}
// Coerce to string. cli returns a number although we request a string.
args.tlsPorts = (args.tlsPorts || "").toString().split(',').filter(exists).map(parsePorts);
tlsPorts = (tlsPorts || "").toString().split(',').filter(exists).map(parsePorts);
args.rvpnConfigs = (args.rvpnConfigs || "").toString().split(',').filter(exists);
if ('false' === args.plainPorts || false === args.plainPorts) {
args.plainPorts = [];
if ('false' === plainPorts || false === plainPorts) {
plainPorts = [];
} else {
args.plainPorts = (args.plainPorts || "65080").toString().split(',').map(parsePorts);
plainPorts = (plainPorts || "65080").toString().split(',').map(parsePorts);
}
pretest = (-1 !== protocols.indexOf('none'));
args.upnp = options.upnp
|| (-1 !== protocols.indexOf('upnp')) || (-1 !== protocols.indexOf('ssdp'));
args.pmp = options.pmp
|| (-1 !== protocols.indexOf('pmp')) || (-1 !== protocols.indexOf('nat-pmp'));
args.mappings = plainPorts.map(function (info) {
info.secure = false;
info.loopback = pretest;
return info;
}).concat(tlsPorts.map(function (info) {
info.secure = true;
info.loopback = pretest;
return info;
}));
//var servers = loopback.create(args);
loopback.create(args);
if (args.debug) {
console.log('[HP] create servers');
//console.log(servers);
}
return hp.create(args).then(function () {
return hp(args).then(function () {
//console.log('[HP] wishing wanting waiting');
console.log('complete, exiting');
process.exit(0);
});
});

57
bin/service.js Normal file
View File

@ -0,0 +1,57 @@
'use strict';
var punch = require('../');
var exec = require('child_process').exec;
function touch() {
exec("/usr/local/bin/ddns --hostname sarah.daplie.com --email 'coolaj86@gmail.com' --agree --token /srv/holepunch/bin/token.jwt", function (err, stdout, stderr) {
if (err || stderr) {
console.error('ddns error:', err || stderr);
}
//console.log(stdout);
});
punch({
mappings: [
{ internal: 80
, external: 80
, secure: false
, loopback: false
}
, { internal: 65080
, external: 65080
, secure: false
, loopback: false
}
, { internal: 65443
, external: 65443
, secure: false
, loopback: false
}
, { internal: 443
, external: 443
, secure: false
, loopback: false
}
, { internal: 65022
, external: 65022
, secure: false
, loopback: false
}
, { internal: 22
, external: 22
, secure: false
, loopback: false
}
]
, upnp: true
, pmp: true
, debug: true
}).then(function (results) {
console.log('map results');
console.log(results);
});
}
setInterval(touch, 90 * 60 * 1000);
touch();

1
etc/holepunch.rc-local Normal file
View File

@ -0,0 +1 @@
nohup /usr/local/bin/node /srv/holepunch/bin/service.js &

17
etc/holepunch.service Normal file
View File

@ -0,0 +1,17 @@
# https://www.digitalocean.com/community/tutorials/how-to-deploy-node-js-applications-using-systemd-and-nginx
# goes in /etc/systemd/system/multi-user.target.wants/holepunch.service
# systemctl enable holepunch
# systemctl start holepunch
[Service]
ExecStart=/usr/local/bin/node /srv/holepunch/bin/service.js
Restart=always
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=node-holepunch
User=root
Group=root
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target

19
etc/holepunch.upstart Normal file
View File

@ -0,0 +1,19 @@
# holepunch - keep connected
#
# The Holepunch Service provides nat traversal via upnp / pmp and ddns
description "Holepunch Service"
start on runlevel [2345]
stop on runlevel [!2345]
respawn
respawn limit 10 5
umask 022
# 'sshd -D' leaks stderr and confuses things in conjunction with 'console log'
console none
# if you used to set SSHD_OPTS in /etc/default/ssh, you can change the
# 'exec' line here instead
exec /usr/local/bin/node /srv/holepunch/bin/service.js

View File

@ -41,7 +41,7 @@ function createSocket() {
});
socket.on('message', function (chunk, info) {
var message = chunk.toString();
var message = chunk.toString('utf8');
console.log('[MESSAGE RECEIVED]');
console.log(info);

View File

@ -1,81 +0,0 @@
'use strict';
var PromiseA = require('bluebird').Promise;
var natpmp = require('nat-pmp');
var exec = require('child_process').exec;
exports.pmpForward = function (port) {
return new PromiseA(function (resolve, reject) {
exec('ip route show default', function (err, stdout, stderr) {
var gw;
if (err || stderr) { reject(err || stderr); return; }
// default via 192.168.1.1 dev eth0
gw = stdout.replace(/^default via (\d+\.\d+\.\d+\.\d+) dev[\s\S]+/m, '$1');
console.log('Possible PMP gateway is', gw);
// create a "client" instance connecting to your local gateway
var client = natpmp.connect(gw);
function setPortForward() {
// setup a new port mapping
client.portMapping({
private: port.private || port.public
, public: port.public || port.private
, ttl: port.ttl || 0 // 600
}, function (err, info) {
if (err) {
reject(err);
return;
}
console.log(info);
// {
// type: 'tcp',
// epoch: 8922109,
// private: 22,
// public: 2222,
// ...
// }
resolve();
});
}
// explicitly ask for the current external IP address
setTimeout(function () {
client.externalIp(function (err, info) {
if (err) throw err;
console.log('Current external IP address: %s', info.ip.join('.'));
setPortForward();
});
});
});
});
};
function usage() {
console.warn("");
console.warn("node helpers/pmp-forward [public port] [private port] [ttl]");
console.warn("");
}
function run() {
var pubPort = parseInt(process.argv[2], 10) || 0;
var privPort = parseInt(process.argv[3], 10) || pubPort;
var ttl = parseInt(process.argv[4], 10) || 0;
var options = { public: pubPort, private: privPort, ttl: ttl };
if (!pubPort) {
usage();
return;
}
exports.pmpForward(options).then(function () {
console.log('done');
});
}
if (require.main === module) {
run();
}

View File

@ -1,36 +1,3 @@
'use strict';
var PromiseA = require('bluebird');
var requestAsync = PromiseA.promisify(require('request'));
module.exports.create = function (args) {
var promises = [];
if (args.debug) {
console.log('[HP] create holepuncher');
console.log(args);
}
// TODO determine if we have a AAAA local ip or not
// TODO get A and AAAA records
if (-1 !== args.protocols.indexOf('none')) {
promises.push(PromiseA.some(args.ipifyUrls.map(function (ipifyUrl) {
return requestAsync('https://' + ipifyUrl).then(function (resp) {
var ip = (resp.body || '').toString('ascii');
if (!/\d+\.\d+\.\d+\.\d+/.test(ip) && !/\w+\:\w+/.test(ip)) {
return PromiseA.reject(new Error("bad response '" + resp.body + "'"));
}
return ip;
});
}), 1));
}
return PromiseA.all(promises).then(function (results) {
if (args.debug) {
console.log('[HP] all done');
console.log(results);
}
});
};
module.exports = require('./lib/index.js');

89
lib/external-ip.js Normal file
View File

@ -0,0 +1,89 @@
'use strict';
var PromiseA = require('bluebird');
//var dns = PromiseA.promisifyAll(require('dns'));
var requestAsync = require('./request');
module.exports = function (opts) {
var promises = [];
/*
// TODO how to support servername
promises.push(dns.resolve4Async(hostname).then(function (ips) {
return ips.map(function (ip) {
return {
address: ip
, family: 'IPv4'
};
});
}));
promises.push(dns.resolve6Async(hostname).then(function (ips) {
return ips.map(function (ip) {
return {
address: ip
, family: 'IPv6'
};
});
}));
*/
function parseIp(ip) {
if (!/\d+\.\d+\.\d+\.\d+/.test(ip) && !/\w+\:\w+/.test(ip)) {
return PromiseA.reject(new Error("bad response '" + ip + "'"));
}
return ip;
}
function ignoreEinval(err) {
if ('EINVAL' === err.code) {
if (opts.debug) {
console.warn('[HP] tried to bind to invalid address:');
console.warn(err.stack);
}
return null;
}
return PromiseA.reject(err);
}
if (opts.debug) {
console.log('[HP] external ip opts:');
console.log(opts);
}
opts.ifaces.forEach(function (iface) {
promises.push(requestAsync({
family: iface.family
, method: 'GET'
, headers: {
Host: opts.hostname
}
, localAddress: iface.address
, servername: opts.hostname // is this actually sent to tls.connect()?
, hostname: opts.hostname // if so we could do the DNS ourselves
// and use the ip address here
, port: opts.port || 443
, pathname: opts.pathname || opts.path || '/'
}).then(parseIp, ignoreEinval).then(function (addr) {
return {
family: iface.family
, address: addr
, localAddress: iface.address
};
}));
});
return PromiseA.all(promises).then(function (results) {
if (opts.debug) {
console.log('[HP] got all ip address types');
console.log(results);
}
return results;
}, function (err) {
console.error('[HP] error');
console.error(err);
});
};

227
lib/index.js Normal file
View File

@ -0,0 +1,227 @@
'use strict';
var PromiseA = require('bluebird');
var os = require('os');
module.exports = function (args) {
if (args.debug) {
console.log('[HP] create holepuncher');
console.log(args);
}
var interfaces = os.networkInterfaces();
var ifacenames = Object.keys(interfaces).filter(function (ifacename) {
// http://www.freedesktop.org/wiki/Software/systemd/PredictableNetworkInterfaceNames/
// https://wiki.archlinux.org/index.php/Network_configuration#Device_names
// we do not include tun and bridge devices because we're trying
// to see if any physical interface is internet-connected first
return /^(en|sl|wl|ww|eth|net|lan|wifi|inet)/.test(ifacename);
});
function getExternalIps() {
if (!args.ipifyUrls || !args.ipifyUrls.length) {
return PromiseA.resolve(args.ifaces.map(function (iface) {
return {
family: iface.family
//, address: addr
, address: iface.address // TODO check where this is used
, localAddress: iface.address
};
}));
}
return PromiseA.any(args.ipifyUrls.map(function (ipifyUrl) {
var getIp = require('./external-ip');
return getIp({ hostname: ipifyUrl, debug: args.debug });
}));
}
function testOpenPort(ip, portInfo) {
var requestAsync = require('./request');
if (args.debug) {
console.log('[HP] hostname', args.loopbackHostname);
}
return requestAsync({
secure: portInfo.secure
, rejectUnauthorized: false
, hostname: ip.address
// '/.well-known/com.daplie.loopback/'
, path: args.loopbackPrefix + args.key
// 'loopback.daplie.invalid'
, servername: args.loopbackHostname
, localAddress: ip.localAddress
, port: portInfo.external || portInfo.internal
, headers: {
// 'loopback.daplie.invalid'
Host: args.loopbackHostname
}
}).then(function (val) {
if (args.debug) {
console.log('[HP] loopback test reached', val);
}
if (val !== args.value) {
return PromiseA.reject(new Error("invalid loopback token value"));
}
ip.validated = true;
ip.ports.push(portInfo);
portInfo.ips.push(ip);
return portInfo;
}, function (err) {
if (args.debug) {
console.log('[HP] loopback did not complete');
console.log(err.stack);
}
return PromiseA.reject(err);
});
}
function testPort(opts) {
// TODO should ip.address === ip.localAddress be treated differently?
// TODO check local firewall?
// TODO would it ever make sense for a public ip to respond to upnp?
// TODO should we pass any or require all?
opts.portInfo.ips = [];
return PromiseA.any(opts.ips.map(function (ip) {
ip.ports = [];
if (opts.debug) {
console.log('[HP] pretest = ', opts.pretest);
}
if (!opts.pretest) {
return PromiseA.reject(new Error("[not an error]: skip the loopback test"));
}
return testOpenPort(ip, opts.portInfo);
}));
}
args.ifaces = ifacenames.reduce(function (all, ifacename) {
var ifs = interfaces[ifacename];
ifs.forEach(function (iface) {
if (!iface.internal && !/^fe80/.test(iface.address)) {
all.push(iface);
}
});
return all;
}, []);
if (args.debug) {
console.log('[HP] external ifaces:');
console.log(args.ifaces);
}
return getExternalIps().then(function (ips) {
var portInfos = args.mappings;
return PromiseA.all(portInfos.map(function (mapping) {
// TODO clone-merge args
return testPort({
portInfo: mapping
, ips: ips
, pretest: mapping.loopback
});
})).then(function (portInfos) {
if (args.debug) {
console.log('[HP] all done on the first try');
console.log(portInfos);
}
return portInfos;
}, function () {
// at least one port could not be mapped
var upnps = [];
var pmps = [];
var pu = PromiseA.resolve();
var pm = PromiseA.resolve();
if (args.upnp) {
if (args.debug) {
console.log('[HP] will try upnp');
}
upnps.push(require('./upnp'));
}
if (args.pmp) {
if (args.debug) {
console.log('[HP] will try nat-pmp');
}
pmps.push(require('./pmp'));
}
return PromiseA.all(portInfos.map(function (portInfo) {
/*
// TODO create single dgram listeners and serialize upnp requests
// because we can't have multiple requests bound to the same port, duh
return PromiseA.any(mappers.map(function (fn) {
var p = fn(args, ips, portInfo);
if (portInfo.ips.length) {
return portInfo;
}
return p;
}));
*/
var good;
function nextu(fn) {
pu = pu.then(function () {
return fn(args, ips, portInfo);
}).then(function (results) {
good = results;
return null;
}, function (/*err*/) {
return null;
});
}
function nextm(fn) {
pm = pm.then(function () {
return fn(args, ips, portInfo);
}).then(function (results) {
good = results;
return null;
}, function (/*err*/) {
return null;
});
}
upnps.forEach(nextu);
pmps.forEach(nextm);
return PromiseA.any([pu, pm]).then(function () {
if (!good) {
return PromiseA.reject(new Error("no port map success"));
}
return null;
});
})).then(function () {
if (args.debug) {
console.log("[HP] all ports successfully mapped");
console.log(portInfos);
}
return portInfos;
});
}).then(function () {
return portInfos;
}, function (err) {
console.warn('[HP] RVPN not implemented');
console.warn(err.stack);
return portInfos;
});
});
};

View File

@ -16,18 +16,20 @@ module.exports.create = function (opts) {
app.use('/', middleware(opts));
(opts.plainPorts||[]).forEach(function (plainPort) {
var plainServer = http.createServer();
plainServer.__plainPort = plainPort;
http.on('request', app);
results.plainServers.push(plainServer);
});
opts.mappings.forEach(function (mapping) {
var server;
(opts.tlsPorts||[]).forEach(function (tlsPort) {
var tlsServer = https.createServer(httpsOptions);
tlsServer.__tlsPort = tlsPort;
http.on('request', app);
results.tlsServers.push(tlsServer);
if (false === mapping.secure) {
server = http.createServer();
server.__plainPort = mapping;
server.on('request', app);
results.plainServers.push(server);
} else {
server = https.createServer(httpsOptions);
server.__tlsPort = mapping;
server.on('request', app);
results.tlsServers.push(server);
}
});
function onListen() {
@ -41,20 +43,47 @@ module.exports.create = function (opts) {
process.nextTick(function () {
results.plainServers.forEach(function (plainServer) {
plainServer.listen(
plainServer.__plainPort.port
, plainServer.__plainPort.address || '0.0.0.0'
, onListen
);
plainServer.on('error', function (err) {
plainServer.error = err;
console.warn("[HP loop] Error with plain HTTP server:");
console.warn(err.stack);
});
try {
plainServer.listen(
plainServer.__plainPort.internal || plainServer.__plainPort.port
, plainServer.__plainPort.address || '0.0.0.0'
, onListen
);
} catch(e) {
plainServer.error = e;
console.warn("[HP loop] Could not create plain HTTP listener:");
console.warn(e.stack);
}
});
results.tlsServers.forEach(function (tlsServer) {
tlsServer.listen(
tlsServer.__tlsPort.port
, tlsServer.__tlsPort.address || '0.0.0.0'
, onListen
);
tlsServer.on('error', function (err) {
tlsServer.error = err;
console.warn("[HP loop] Error with HTTPS server:");
console.warn(err.stack);
});
try {
tlsServer.listen(
tlsServer.__tlsPort.internal || tlsServer.__tlsPort.port
, tlsServer.__tlsPort.address || '0.0.0.0'
, onListen
);
} catch(e) {
tlsServer.error = e;
console.warn("[HP loop] Could not create HTTPS listener:");
console.warn(e.stack);
}
});
});
results.key = opts.key;
results.value = opts.value;
results.loopbackHostname = opts.loopbackHostname;
results.loopbackPrefix = opts.loopbackPrefix;
return results;
};

View File

@ -6,14 +6,20 @@ function middleware(opts) {
var key = opts.key;
var val = opts.value;
var vhost = opts.vhost;
var pathnamePrefix = opts.prefix || '/.well-known/com.daplie.loopback/';
var defaultHostname = 'loopback.daplie.invalid';
var pathnamePrefix = opts.loopbackPrefix;
var defaultHostname = opts.loopbackHostname;
if (!defaultHostname) {
defaultHostname = opts.loopbackHostname = 'loopback.daplie.invalid';
}
if (!pathnamePrefix) {
pathnamePrefix = opts.loopbackPrefix = '/.well-known/com.daplie.loopback/';
}
if (!key) {
opts.key = require('crypto').randomBytes(8).toString('hex');
key = opts.key = require('crypto').randomBytes(8).toString('hex');
}
if (!val) {
opts.value = require('crypto').randomBytes(16).toString('hex');
val = opts.value = require('crypto').randomBytes(16).toString('hex');
}
if (!vhost && vhost !== false) {
vhost = defaultHostname;
@ -37,16 +43,20 @@ function middleware(opts) {
if (0 !== urlpath.indexOf(pathnamePrefix)) {
if (opts.debug) {
console.log("[HP] Pathname '" + urlpath + "'"
+ " failed to match '" + pathnamePrefix + "'");
+ " failed to match prefix '" + pathnamePrefix + ": "
+ urlpath.indexOf(pathnamePrefix)
);
}
next();
return;
}
if (scmp(key, urlpath.substr(pathnamePrefix.length, key.length))) {
if (!scmp(key, urlpath.substr(pathnamePrefix.length))) {
if (opts.debug) {
console.log("[HP] Pathname '" + urlpath + "'"
+ " failed to match '" + pathnamePrefix + key + "'");
console.log("[HP] key '" + urlpath.substr(pathnamePrefix.length) + "'"
+ " failed to match '" + key + "': "
+ scmp(key, urlpath.substr(pathnamePrefix.length))
);
}
next();
return;

130
lib/pmp.js Normal file
View File

@ -0,0 +1,130 @@
'use strict';
var PromiseA = require('bluebird');
var natpmp = require('holepunch-nat-pmp');
function getGateway() {
var exec = require('child_process').exec;
var netroute;
var gw;
try {
netroute = require('netroute');
gw = netroute.getGateway();
} catch(e) {
}
if (gw) {
return PromiseA.resolve(gw);
}
return new PromiseA(function (resolve, reject) {
exec('ip route show default', function (err, stdout, stderr) {
var gw;
if (err || stderr) { reject(err || stderr); return; }
// default via 192.168.1.1 dev eth0
gw = stdout.replace(/^default via (\d+\.\d+\.\d+\.\d+) dev[\s\S]+/m, '$1');
console.log('Possible PMP gateway is', gw);
return gw;
});
});
}
function pmpForwardHelper(gw, portInfo) {
return new PromiseA(function (resolve, reject) {
// create a "client" instance connecting to your local gateway
var client = natpmp.connect(gw);
client.on('error', function (err) {
reject(err);
});
function setPortForward() {
// setup a new port mapping
client.portMapping({
private: portInfo.internal || portInfo.private
|| portInfo.external || portInfo.public
, public: portInfo.external || portInfo.public
|| portInfo.internal || portInfo.private
, ttl: portInfo.ttl || 7200 // 0 // 600
}, function (err, info) {
if (err) {
reject(err);
return;
}
console.log(info);
// {
// type: 'tcp',
// epoch: 8922109,
// private: 22,
// public: 2222,
// ...
// }
client.close();
resolve();
});
}
// explicitly ask for the current external IP address
// TODO why did I use a setTimeout here? event loop / timing bug?
setTimeout(function () {
client.externalIp(function (err, info) {
if (err) {
console.error('[HP] Error: setTimeout client.externalIp:');
console.error(err.stack);
return PromiseA.reject(err);
}
console.log('Current external IP address: %s', info.ip.join('.'));
setPortForward();
});
});
});
}
function pmpForward(portInfo) {
return getGateway().then(function (gw) {
return pmpForwardHelper(gw, portInfo);
});
}
module.exports = function (args, ips, portInfo) {
if (args.debug) {
console.log('[HP] [pmp] portInfo');
console.log(portInfo);
}
return pmpForward(portInfo);
};
module.exports.pmpForward = pmpForward;
/*
function usage() {
console.warn("");
console.warn("node helpers/pmp-forward [public port] [private port] [ttl]");
console.warn("");
}
function run() {
var pubPort = parseInt(process.argv[2], 10) || 0;
var privPort = parseInt(process.argv[3], 10) || pubPort;
var ttl = parseInt(process.argv[4], 10) || 0;
var options = { public: pubPort, private: privPort, ttl: ttl };
if (!pubPort) {
usage();
return;
}
exports.pmpForward(options).then(function () {
console.log('done');
});
}
if (require.main === module) {
run();
}
*/

57
lib/request.js Normal file
View File

@ -0,0 +1,57 @@
'use strict';
var PromiseA = require('bluebird');
var https = PromiseA.promisifyAll(require('https'));
var http = PromiseA.promisifyAll(require('http'));
function requestAsync(opts) {
return new PromiseA(function (resolve, reject) {
var httpr = (false === opts.secure) ? http : https;
if (opts.debug) {
console.log('[HP] requestAsync opts');
console.log(opts);
}
var req = httpr.request(opts, function (res) {
var data = '';
res.on('error', function (err) {
if (opts.debug) {
console.error('[Error] HP: bad request:');
console.error(err);
}
reject(err);
});
res.on('data', function (chunk) {
clearTimeout(req.__timtok);
if (opts.debug > 2) {
console.log('HP: request chunk:');
console.log(chunk);
}
data += chunk.toString('utf8');
});
res.on('end', function () {
if (opts.debug > 2) {
console.log('HP: request complete:');
console.log(data);
}
resolve(data);
});
});
req.on('error', reject);
req.setTimeout(3 * 1000);
req.on('socket', function (socket) {
req.__timtok = setTimeout(function () {
req.abort();
}, 3 * 1000);
socket.setTimeout(3 * 1000);
});
req.end();
});
}
module.exports = requestAsync;

View File

@ -1,15 +1,33 @@
'use strict';
var PromiseA = require('bluebird').Promise;
var natUpnp = require('nat-upnp');
var natUpnp = require('holepunch-upnp');
var client;
function upnpForward(opts) {
if (opts.debug) {
console.log('[HP] [upnp] opts');
console.log(opts);
}
function useClient(client) {
if (opts.debug) {
console.log('[HP] [upnp] created client');
console.log(client);
}
exports.upnpForward = function (port) {
return natUpnp.createClient({ timeout: 1800 }).then(function (client) {
return client.portMapping({
public: port.public,
private: port.private || port.public,
ttl: port.ttl || 0
})/*.then(function () {
public: opts.external || opts.public || opts.internal
, private: opts.internal || opts.private || opts.public || opts.external
, ttl: opts.ttl || 0
}).then(function (result) {
if (opts.debug) {
console.log('[HP] [upnp] result');
console.log(result);
}
return result;
});
/*.then(function () {
var promitter = client.getMappings();
promitter.on('entry', function (entry, i) {
@ -21,10 +39,33 @@ exports.upnpForward = function (port) {
});
return promitter;
})*/;
});*/
}
if (client) {
return useClient(client);
} else {
return natUpnp.createClient({ timeout: 3 * 1000 }).then(function (_client) {
client = _client;
useClient(client);
});
}
}
module.exports = function (args, ips, portInfo) {
// TODO ips.forEach
return upnpForward({
debug: args.debug
, private: portInfo.internal
, internal: portInfo.internal
, public: portInfo.external
, external: portInfo.external
// , localAddress: ip.localAddress
});
};
module.exports.upnpForward = upnpForward;
/*
client.portUnmapping({
public: 80
@ -69,9 +110,9 @@ function run() {
exports.upnpForward(options).then(function () {
console.log('done');
}).catch(function (err) {
console.error('ERROR');
console.error(err);
throw err;
console.error('[HP] Error: upnpForward:');
console.error(err.stack);
return PromiseA.reject(err);
});
}

View File

@ -1,124 +0,0 @@
'use strict';
var https = require('https');
var path = require('path');
var fs = require('fs');
var PromiseA = global.Promise || require('bluebird').Promise;
exports.create = function (ip, localPort, externalPort) {
return new PromiseA(function (resolve, reject) {
var token = Math.random().toString(16).split('.')[1];
var tokenPath = Math.random().toString(16).split('.')[1];
var options;
var server;
var options;
var certsPath = path.join(__dirname, 'certs', 'server');
var caCertsPath = path.join(__dirname, 'certs', 'ca');
function testConnection() {
var awesome = false;
var timetok;
var webreq;
var options = {
// not hostname because we set headers.host on our own
host: ip
, headers: {
// whatever's on the fake cert
'Host': 'redirect-www.org'
}
, port: externalPort
, path: '/' + tokenPath
, ca: fs.readFileSync(path.join(caCertsPath, 'my-root-ca.crt.pem'))
};
options.agent = new https.Agent(options);
timetok = setTimeout(function () {
reject(new Error("timed out while testing NAT loopback for port " + externalPort));
}, 2000);
function finishHim(err) {
clearTimeout(timetok);
server.close(function () {
if (!err && awesome) {
resolve();
}
});
if (err || !awesome) {
if (err) {
reject(err);
}
else if (!awesome) {
reject(new Error("loopback failed. Why? here's my best guess: "
+ "the ssl cert matched, so you've probably got two boxes and this isn't the right one"));
}
return;
}
}
webreq = https.request(options, function(res) {
res.on('data', function (resToken) {
if (resToken.toString() === token) {
awesome = true;
return;
}
});
res.on('error', function (err) {
console.error('[ERROR] https.request.response');
console.error(err);
finishHim(new Error("loopback failed. Why? here's my best guess: "
+ "the connection was interrupted"));
});
res.on('end', function () {
finishHim();
});
});
webreq.on('error', function (err) {
console.error('[ERROR] https.request');
console.error(err);
if (/ssl|cert|chain/i.test(err.message || err.toString())) {
finishHim(new Error("loopback failed. Why? here's my best guess: "
+ "the ssl cert validation may have failed (might port-forward to the wrong box)"));
} else {
finishHim(new Error("loopback failed. Why? here's my best guess: "
+ "port forwarding isn't configured for " + ip + ":" + externalPort + " to " + localPort));
}
});
webreq.end();
}
//
// SSL Certificates
//
options = {
key: fs.readFileSync(path.join(certsPath, 'my-server.key.pem'))
, ca: [ fs.readFileSync(path.join(caCertsPath, 'my-root-ca.crt.pem')) ]
, cert: fs.readFileSync(path.join(certsPath, 'my-server.crt.pem'))
, requestCert: false
, rejectUnauthorized: false
};
//
// Serve an Express App securely with HTTPS
//
server = https.createServer(options);
function listen(app) {
server.on('request', app);
server.listen(localPort, function () {
localPort = server.address().port;
setTimeout(testConnection, 2000);
});
}
listen(function (req, res) {
if (('/' + tokenPath) === req.url) {
res.end(token);
return;
}
res.end('loopback failure');
});
});
};

View File

@ -1,15 +1,26 @@
{
"name": "holepunch",
"version": "1.0.0-alpha.1",
"version": "1.0.0-alpha.2",
"description": "Get a direct ip connection by any means possible - direct (public ip), upnp (Microsoft), nat-pmp (Apple), or punch a hole through a firewall using a Reverse VPN (Daplie).",
"main": "index.js",
"bin": {
"holepunch": "holepunch.js"
"holepunch": "bin/holepunch.js"
},
"directories": {
"example": "examples"
},
"dependencies": {},
"dependencies": {
"bluebird": "^3.1.1",
"cli": "^0.11.1",
"express": "^4.13.3",
"holepunch-nat-pmp": "^1.0.0-alpha.2",
"holepunch-upnp": "^1.0.0-alpha.2",
"localhost.daplie.com-certificates": "^1.1.2",
"scmp": "^1.0.0"
},
"optionalDependencies": {
"netroute": "^1.0.2"
},
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"

17
tests/upnp.js Normal file
View File

@ -0,0 +1,17 @@
'use strict';
var upnp = require('../lib/upnp');
var opts = {
debug: true
};
var ips = [];
var portInfo = {
internal: 65080
, external: 65080
};
upnp(opts, ips, portInfo).then(function (result) {
console.log('results', result);
}, function (err) {
console.error('error', err.stack);
});