22 Commits

Author SHA1 Message Date
Roman Shtylman
9487797e02 1.1.0 2014-02-24 19:51:14 -05:00
Roman Shtylman
a42f6a8d8d document local-host option 2014-02-24 19:49:40 -05:00
Roman Shtylman
14b4bcb96f add tests for host header transform 2014-02-24 19:43:34 -05:00
Roman Shtylman
4aa65002eb use host header transform only when local-host is specified 2014-02-24 19:43:07 -05:00
Fredi Pevcin
08676ba81d Set Host header accordingly to local-host option 2014-02-24 00:26:38 +01:00
Roman Shtylman
174e7f3982 1.0.0 2014-02-14 00:37:45 -05:00
Roman Shtylman
44be55cd7b readme: fix markdown table
[ci skip]
2014-02-14 00:36:53 -05:00
Roman Shtylman
5c6cd2359c add travis badge
- remove node 0.8
- add node 0.10
2014-02-14 00:34:40 -05:00
Roman Shtylman
2f6f9459ad change main export signature to localtunnel(port, opt, fn)
Makes for a simpler hello world app
2014-02-14 00:32:43 -05:00
Roman Shtylman
7217a08a05 add history.md to track changes 2014-02-13 23:52:12 -05:00
Roman Shtylman
fbfc923a7e remove connect export in favor of single function
Since connect was the only function exported, we can just export the
function directly to make things simpler.
2014-02-13 23:51:29 -05:00
Roman Shtylman
d9bc11b520 default host to localtuunel.me
If no host specified, then default to localtunnel.me

close #31
2014-02-13 23:43:54 -05:00
Roman Shtylman
ad64611bd1 add tests 2014-02-13 23:41:49 -05:00
Roman Shtylman
ac70515143 0.2.2 2014-01-09 11:07:18 -05:00
Roman Shtylman
8d7ccccf21 remove local.unpipe() on remote close
This will happen automatically.

close #28
2014-01-09 11:06:58 -05:00
Roman Shtylman
77091b3d93 0.2.1 2013-12-31 17:34:04 -05:00
Roman Shtylman
4f4a147b45 don't unpipe on local close
Pipe will do this for us
2013-12-31 17:33:49 -05:00
Roman Shtylman
f1d809a84d 0.2.0 2013-12-31 15:39:16 -05:00
Roman Shtylman
eba003bd26 add a .close method to shutdown the tunnel 2013-12-31 15:38:45 -05:00
Roman Shtylman
3354c4c6e3 rework tunnel logic
Refactoring to make things a bit saner and easier to debug.
2013-12-05 11:26:19 -05:00
Roman Shtylman
1c2757e604 Merge pull request #27 from adammck/couldnt-establish-tunnel
Add more verbose error for ECONNREFUSED
2013-11-20 09:34:48 -08:00
Adam Mckaig
790e55e881 Add more verbose error for ECONNREFUSED
If the tunnel server can be reached (at e.g. http://localtunnel.me/?new)
but the tunnel (to e.g. grpi.localtunnel.me:44827) can't actually be
established, the client currently gets stuck in a loop retrying forever
with no indication as to what's wrong. This doesn't fix the loop, since
it does seem desirable to retry forever, but logs:

    [Error: connection refused: localtunnel.me:44827]
2013-11-20 12:29:31 -05:00
7 changed files with 445 additions and 152 deletions

View File

@@ -1,4 +1,3 @@
language: node_js language: node_js
node_js: node_js:
- 0.8 - "0.10"
- 0.9

7
History.md Normal file
View File

@@ -0,0 +1,7 @@
# 1.0.0 / 2014-02-14
* default to localltunnel.me for host
* remove exported `connect` method (just export one function that does the same thing)
* change localtunnel signature to (port, opt, fn)
# 0.2.2 / 2014-01-09

View File

@@ -1,4 +1,4 @@
# localtunnel # # localtunnel [![Build Status](https://travis-ci.org/defunctzombie/localtunnel.png?branch=master)](https://travis-ci.org/defunctzombie/localtunnel) #
localtunnel exposes your localhost to the world for easy testing and sharing! No need to mess with DNS or deploy just to have others test out your changes. localtunnel exposes your localhost to the world for easy testing and sharing! No need to mess with DNS or deploy just to have others test out your changes.
@@ -14,7 +14,7 @@ This will install the localtunnel module globally and add the 'lt' client cli to
## use ## ## use ##
Super Easy! Assuming your local server is running on port 8000, just use the ```lt``` command to start the tunnel. Assuming your local server is running on port 8000, just use the ```lt``` command to start the tunnel.
``` ```
lt --port 8000 lt --port 8000
@@ -24,31 +24,53 @@ Thats it! It will connect to the tunnel server, setup the tunnel, and tell you w
You can restart your local server all you want, ```lt``` is smart enough to detect this and reconnect once it is back. You can restart your local server all you want, ```lt``` is smart enough to detect this and reconnect once it is back.
### arguments
Below are some common arguments. See `lt --help` for additional arguments
* `--subdomain` request a named subdomain on the localtunnel server (default is random characters)
* `--local-host` proxy to a hostname other than localhost
## API ## ## API ##
The localtunnel client is also usable through an API (for test integration, automation, etc) The localtunnel client is also usable through an API (for test integration, automation, etc)
### localtunnel(port [,opts], fn)
Creates a new localtunnel to the specified local `port`. `fn` will be called once you have been assigned a public localtunnel url. `opts` can be used to request a specific `subdomain`.
```javascript ```javascript
var localtunnel = require('localtunnel'); var localtunnel = require('localtunnel');
var client = localtunnel.connect({ localtunnel(port, function(err, tunnel) {
// the localtunnel server if (err) ...
host: 'http://localtunnel.me',
// your local application port
port: 12345
});
// when your are assigned a url // the assigned public url for your tunnel
client.on('url', function(url) { // i.e. https://abcdefgjhij.localtunnel.me
// you can now make http requests to the url tunnel.url;
// they will be proxied to your local server on port [12345]
});
client.on('error', function(err) {
// uh oh!
}); });
``` ```
### opts
* `subdomain` A *string* value requesting a specific subdomain on the proxy server. **Note** You may not actually receive this name depending on availablily.
* `local_host` Proxy to this hostname instead of `localhost`. This will also cause the `Host` header to be re-written to this value in proxied requests.
### Tunnel
The `tunnel` instance returned to your callback emits the following events
|event|args|description|
|----|----|----|
|error|err|fires when an error happens on the tunnel|
|close||fires when the tunnel has closed|
The `tunnel instance has the following methods
|method|args|description|
|----|----|----|
|close||close the tunnel|
## other clients ## ## other clients ##
Clients in other languages Clients in other languages
@@ -57,7 +79,7 @@ Clients in other languages
## server ## ## server ##
See shtylman/localtunnel-server for details on the server that powers localtunnel. See defunctzombie/localtunnel-server for details on the server that powers localtunnel.
## License ## ## License ##
MIT MIT

View File

@@ -25,15 +25,17 @@ var opt = {
subdomain: argv.subdomain, subdomain: argv.subdomain,
} }
var client = lt_client.connect(opt); lt_client(opt.port, opt, function(err, tunnel) {
if (err) {
console.error(err);
return process.exit(1);
}
// only emitted when the url changes console.log('your url is: %s', tunnel.url);
client.on('url', function(url) {
console.log('your url is: %s', url);
});
client.on('error', function(err) { tunnel.on('error', function(err) {
console.error(err); console.error(err);
});
}); });
// vim: ft=javascript // vim: ft=javascript

391
client.js
View File

@@ -2,28 +2,178 @@ var net = require('net');
var url = require('url'); var url = require('url');
var EventEmitter = require('events').EventEmitter; var EventEmitter = require('events').EventEmitter;
var after = require('after');
var request = require('request'); var request = require('request');
var debug = require('debug')('localtunnel:client');
// request upstream url and connection info var stream = require('stream');
var request_url = function(params, cb) { var util = require('util');
request(params, function(err, res, body) {
if (err) {
return cb(err);
}
cb(null, body); var Transform = stream.Transform;
});
var HeaderHostTransformer = function(opts) {
if (!(this instanceof HeaderHostTransformer)) {
return new HeaderHostTransformer(opts);
}
opts = opts || {}
Transform.call(this, opts);
var self = this;
self.host = opts.host || 'localhost';
self.replaced = false;
}
util.inherits(HeaderHostTransformer, Transform);
HeaderHostTransformer.prototype._transform = function (chunk, enc, cb) {
var self = this;
chunk = chunk.toString();
// after replacing the first instance of the Host header
// we just become a regular passthrough
self.push(chunk.replace(/(\r\nHost: )\S+/, function(match, $1) {
self._transform = undefined;
return $1 + self.host;
}));
cb();
}; };
var connect = function(opt) { // manages groups of tunnels
var ev = new EventEmitter(); var TunnelCluster = function(opt) {
if (!(this instanceof TunnelCluster)) {
return new TunnelCluster(opt);
}
var self = this;
self._opt = opt;
EventEmitter.call(self);
};
TunnelCluster.prototype.__proto__ = EventEmitter.prototype;
// establish a new tunnel
TunnelCluster.prototype.open = function() {
var self = this;
var opt = self._opt || {};
var remote_host = opt.remote_host;
var remote_port = opt.remote_port;
// local host
var local_host = opt.local_host; var local_host = opt.local_host;
var local_port = opt.local_port;
// local port debug('establishing tunnel %s:%s <> %s:%s', local_host, local_port, remote_host, remote_port);
var local_port = opt.port;
// connection to localtunnel server
var remote = net.connect({
host: remote_host,
port: remote_port
});
remote.once('error', function(err) {
// emit connection refused errors immediately, because they
// indicate that the tunnel can't be established.
if (err.code === 'ECONNREFUSED') {
self.emit('error', new Error('connection refused: ' + remote_host + ':' + remote_port + ' (check your firewall settings)'));
}
else {
self.emit('error', err);
}
setTimeout(function() {
self.emit('dead');
}, 1000);
});
function conn_local() {
debug('connecting locally to %s:%d', local_host, local_port);
if (remote.destroyed) {
self.emit('dead');
return;
}
remote.pause();
// connection to local http server
var local = net.connect({
host: local_host,
port: local_port
});
function remote_close() {
self.emit('dead');
local.end();
};
remote.once('close', remote_close);
local.on('error', function(err) {
local.end();
remote.removeListener('close', remote_close);
if (err.code !== 'ECONNREFUSED') {
return local.emit('error', err);
}
// retrying connection to local server
setTimeout(conn_local, 1000);
});
local.once('connect', function() {
debug('connected locally');
remote.resume();
var stream = remote;
// if user requested something other than localhost
// then we use host header transform to replace the host header
if (local_host !== 'localhost') {
stream = remote.pipe(HeaderHostTransformer({ host: local_host }));
}
stream.pipe(local).pipe(remote);
// when local closes, also get a new remote
local.once('close', function(had_error) {
debug('local connection closed [%s]', had_error);
});
});
}
// tunnel is considered open when remote connects
remote.once('connect', function() {
self.emit('open', remote);
});
remote.once('connect', conn_local);
};
var Tunnel = function(opt) {
if (!(this instanceof Tunnel)) {
return new Tunnel(opt);
}
var self = this;
self._closed = false;
self._opt = opt || {};
self._opt.host = self._opt.host || 'https://localtunnel.me';
};
Tunnel.prototype.__proto__ = EventEmitter.prototype;
// initialize connection
// callback with connection info
Tunnel.prototype._init = function(cb) {
var self = this;
var opt = self._opt;
var params = {
path: '/',
json: true
};
var base_uri = opt.host + '/'; var base_uri = opt.host + '/';
@@ -33,128 +183,123 @@ var connect = function(opt) {
// no subdomain at first, maybe use requested domain // no subdomain at first, maybe use requested domain
var assigned_domain = opt.subdomain; var assigned_domain = opt.subdomain;
// connect to upstream given connection parameters
var tunnel = function (remote_host, remote_port, dead) {
var remote_opt = {
host: remote_host,
port: remote_port
};
var local_opt = {
host: local_host,
port: local_port
};
var remote_attempts = 0;
(function conn(conn_had_error) {
if (conn_had_error) {
return;
}
// we need a new tunnel
if (++remote_attempts >= 3) {
return dead();
}
// connection to localtunnel server
var remote = net.connect(remote_opt);
remote.once('error', function(err) {
if (err.code !== 'ECONNREFUSED') {
remote.emit('error', err);
}
// retrying connection to local server
setTimeout(conn, 1000);
});
function recon_local() {
remote.pause();
remote_attempts = 0;
// connection to local http server
var local = net.connect(local_opt);
local.once('error', function(err) {
if (err.code !== 'ECONNREFUSED') {
local.emit('error', err);
}
// retrying connection to local server
setTimeout(recon_local, 1000);
});
local.once('connect', function() {
remote.resume();
remote.pipe(local).pipe(remote, {end: false});
});
local.once('close', function(had_error) {
if (had_error) {
return;
}
recon_local();
});
}
remote.once('close', conn);
remote.once('connect', recon_local);
})();
};
var params = {
path: '/',
json: true
};
// where to quest // where to quest
params.uri = base_uri + ((assigned_domain) ? assigned_domain : '?new'); params.uri = base_uri + ((assigned_domain) ? assigned_domain : '?new');
function init_tunnel() { (function get_url() {
// get an id from lt server and setup forwarding tcp connections request(params, function(err, res, body) {
request_url(params, function(err, body) {
if (err) { if (err) {
ev.emit('error', new Error('tunnel server not available: ' + err.message + ', retry 1s')); // TODO (shtylman) don't print to stdout?
console.log('tunnel server offline: ' + err.message + ', retry 1s');
// retry interval for id request return setTimeout(get_url, 1000);
return setTimeout(function() {
init_tunnel();
}, 1000);
} }
// our assigned hostname and tcp port
var port = body.port; var port = body.port;
var host = upstream.hostname; var host = upstream.hostname;
// store the id so we can try to get the same one
assigned_domain = body.id;
var max_conn = body.max_conn_count || 1; var max_conn = body.max_conn_count || 1;
// after all our tunnels die, we ask for new ones cb(null, {
// this might happen if the upstream server dies remote_host: upstream.hostname,
var dead = after(max_conn, function() { remote_port: body.port,
init_tunnel(); name: body.id,
url: body.url,
max_conn: max_conn
}); });
for (var count = 0 ; count < max_conn ; ++count) {
tunnel(host, port, dead);
}
ev.emit('url', body.url);
}); });
} })();
init_tunnel();
return ev;
}; };
module.exports.connect = connect; Tunnel.prototype._establish = function(info) {
var self = this;
var opt = self._opt;
// for backwards compatibility info.local_host = opt.local_host || 'localhost';
// old localtunnel modules had server and client code in same module info.local_port = opt.port;
// so to keep .client working we expose it here
module.exports.client = module.exports; var tunnels = self.tunnel_cluster = TunnelCluster(info);
// only emit the url the first time
tunnels.once('open', function() {
self.emit('url', info.url);
});
var tunnel_count = 0;
// track open count
tunnels.on('open', function(tunnel) {
tunnel_count++;
debug('tunnel open [total: %d]', tunnel_count);
var close_handler = function() {
tunnel.destroy();
};
if (self._closed) {
return close_handler();
}
self.once('close', close_handler);
tunnel.once('close', function() {
self.removeListener('close', close_handler);
});
});
// when a tunnel dies, open a new one
tunnels.on('dead', function(tunnel) {
tunnel_count--;
debug('tunnel dead [total: %d]', tunnel_count);
if (self._closed) {
return;
}
tunnels.open();
});
// establish as many tunnels as allowed
for (var count = 0 ; count < info.max_conn ; ++count) {
tunnels.open();
}
};
Tunnel.prototype.open = function(cb) {
var self = this;
self._init(function(err, info) {
if (err) {
return cb(err);
}
self.url = info.url;
self._establish(info);
cb();
});
};
// shutdown tunnels
Tunnel.prototype.close = function() {
var self = this;
self._closed = true;
self.emit('close');
};
module.exports = function localtunnel(port, opt, fn) {
if (typeof opt === 'function') {
fn = opt;
opt = {};
}
opt = opt || {};
opt.port = port;
var client = Tunnel(opt);
client.open(function(err) {
if (err) {
return fn(err);
}
fn(null, client);
});
};

View File

@@ -2,7 +2,7 @@
"author": "Roman Shtylman <shtylman@gmail.com>", "author": "Roman Shtylman <shtylman@gmail.com>",
"name": "localtunnel", "name": "localtunnel",
"description": "expose localhost to the world", "description": "expose localhost to the world",
"version": "0.1.3", "version": "1.1.0",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git://github.com/shtylman/localtunnel.git" "url": "git://github.com/shtylman/localtunnel.git"
@@ -10,9 +10,14 @@
"dependencies": { "dependencies": {
"request": "2.11.4", "request": "2.11.4",
"optimist": "0.3.4", "optimist": "0.3.4",
"after": "0.8.1" "debug": "0.7.4"
},
"devDependencies": {
"mocha": "~1.17.0"
},
"scripts": {
"test": "mocha --ui qunit --reporter list -- test/index.js"
}, },
"devDependencies": {},
"bin": { "bin": {
"lt": "./bin/client" "lt": "./bin/client"
}, },

113
test/index.js Normal file
View File

@@ -0,0 +1,113 @@
var http = require('http');
var https = require('https');
var url = require('url');
var assert = require('assert');
var localtunnel = require('../');
test('setup local http server', function(done) {
var server = http.createServer();
server.on('request', function(req, res) {
res.write(req.headers.host);
res.end();
});
server.listen(function() {
var port = server.address().port;
test._fake_port = port;
console.log('local http on:', port);
done();
});
});
test('setup localtunnel client', function(done) {
localtunnel(test._fake_port, function(err, tunnel) {
assert.ifError(err);
assert.ok(new RegExp('^https:\/\/.*localtunnel.me' + '$').test(tunnel.url));
test._fake_url = tunnel.url;
done();
});
});
test('query localtunnel server w/ ident', function(done) {
var uri = test._fake_url;
var parsed = url.parse(uri);
var opt = {
host: parsed.host,
port: 443,
headers: {
host: parsed.hostname
},
path: '/'
};
var req = https.request(opt, function(res) {
res.setEncoding('utf8');
var body = '';
res.on('data', function(chunk) {
body += chunk;
});
res.on('end', function() {
assert(/.*[.]localtunnel[.]me/.test(body), body);
done();
});
});
req.end();
});
test('request specific domain', function(done) {
localtunnel(test._fake_port, { subdomain: 'abcd' }, function(err, tunnel) {
assert.ifError(err);
assert.ok(new RegExp('^https:\/\/abcd.localtunnel.me' + '$').test(tunnel.url));
tunnel.close();
done();
});
});
suite('local-host');
test('setup localtunnel client', function(done) {
var opt = {
local_host: '127.0.0.1'
};
localtunnel(test._fake_port, opt, function(err, tunnel) {
assert.ifError(err);
assert.ok(new RegExp('^https:\/\/.*localtunnel.me' + '$').test(tunnel.url));
test._fake_url = tunnel.url;
done();
});
});
test('override Host header with local-host', function(done) {
var uri = test._fake_url;
var parsed = url.parse(uri);
var opt = {
host: parsed.host,
port: 443,
headers: {
host: parsed.hostname
},
path: '/'
};
var req = https.request(opt, function(res) {
res.setEncoding('utf8');
var body = '';
res.on('data', function(chunk) {
body += chunk;
});
res.on('end', function() {
assert.equal(body, '127.0.0.1');
done();
});
});
req.end();
});