34 Commits

Author SHA1 Message Date
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
Roman Shtylman
a9b0274ff4 0.1.3 2013-11-14 12:10:02 -05:00
Roman Shtylman
83ecb29eff Merge pull request #26 from EverythingMe/override_localhost
Added the --localhost parameter to tunnel the traffic to other hosts
2013-11-14 09:08:47 -08:00
Omri Bahumi
21df257d16 Added the --local-host parameter to tunnel the traffic to other hosts 2013-11-14 18:06:19 +02:00
Roman Shtylman
18ada0854a 0.1.2 2013-11-06 23:25:28 -05:00
Roman Shtylman
34afd6537d more resilient to upstream server failure and restart 2013-11-06 23:25:05 -05:00
Roman Shtylman
2c38aefb9d add go client to readme 2013-10-23 11:31:28 -04:00
Roman Shtylman
aa488f6e76 0.1.1 2013-10-22 15:57:36 -04:00
Roman Shtylman
f6618953f9 Merge pull request #20 from eagleeye/master
Do not call success callback right after error in request_url
2013-10-16 18:49:48 -07:00
Andrii Shumada
092d050fa0 Do not call success callback right after error in request_url 2013-10-16 12:05:37 +03:00
Roman Shtylman
0334ace20b 0.1.0 2013-06-17 02:13:17 -04:00
Roman Shtylman
13afcff1ae fix README api example 2013-06-17 02:12:54 -04:00
Roman Shtylman
ed5aa3f16b remove server components
moved to localtunnel-server repo
2013-06-17 02:11:41 -04:00
Roman Shtylman
2fcac1336c add debug module to deps 2013-06-16 18:35:55 -04:00
Roman Shtylman
0568ae0bef close client tcp sockets after each http response
While a little less efficient than keeping tcp connections open, this
helps ensure that bad things don't happen on the socket connections when
http protocol issues happen.
2013-06-16 18:24:27 -04:00
Roman Shtylman
585a8afad7 fix undefined variable
fixes #8
2013-02-11 14:19:34 -05:00
Roman Shtylman
fbe841a1c5 add node 0.9 for travis testing 2013-01-26 13:01:16 -05:00
Roman Shtylman
929473913f add notes about running your own server 2012-12-24 03:14:19 -05:00
Roman Shtylman
5340659954 0.0.4 2012-12-17 14:32:31 -05:00
Roman Shtylman
5c6558ed91 fix missing query parsing in server 2012-12-17 14:31:05 -05:00
Roman Shtylman
79ca069c38 refactor to use http-raw for lt server incoming
- http raw exposes a socket to the req/res pair
- cleanup client to be more resilient
- add test for queued requests
2012-12-17 14:23:12 -05:00
Roman Shtylman
741db27084 fix domain typo in readme
localtunnel.me not .com
2012-12-14 23:43:06 -05:00
Roman Shtylman
b605e9b823 server: make sure client id are released when unused
When clients disconnect, their tcp server should be shutdown and the id
released after a grace period.
2012-11-14 13:53:33 -05:00
Roman Shtylman
b5830c3840 0.0.3 2012-11-06 10:55:24 -05:00
Roman Shtylman
06b85ad0aa expose client and server api 2012-11-06 10:55:08 -05:00
Roman Shtylman
943a7dc35b typo 2012-11-05 16:05:32 -05:00
Roman Shtylman
c46a94b7a0 0.0.2 2012-11-03 15:33:32 -04:00
Roman Shtylman
2f692b8e29 expose client as a library
- Allows for using localtunnel from code instead of manually invoking
- add tests
- add travis config
- add travis badge
2012-11-03 15:16:30 -04:00
Roman Shtylman
51d91ce0e8 refactor server tcp handling
- limit on number of tcp connections
- preliminary support for websockets
2012-10-17 22:50:59 -04:00
Roman Shtylman
ab28444802 add server launcher to bin 2012-10-17 18:59:09 -04:00
7 changed files with 312 additions and 421 deletions

4
.travis.yml Normal file
View File

@@ -0,0 +1,4 @@
language: node_js
node_js:
- 0.8
- 0.9

View File

@@ -2,7 +2,7 @@
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.
Great for working with browser testing tools like browserling or external api callback services like twilio which require a public url for callbacks!
Great for working with browser testing tools like browserling or external api callback services like twilio which require a public url for callbacks.
## installation ##
@@ -10,9 +10,11 @@ Great for working with browser testing tools like browserling or external api ca
npm install -g localtunnel
```
This will install the localtunnel module globally and add the 'lt' client cli tool to your PATH.
## use ##
Super East! Assuming your local server is running on port 8000, just use the ```lt``` command to start the tunnel.
Super Easy! Assuming your local server is running on port 8000, just use the ```lt``` command to start the tunnel.
```
lt --port 8000
@@ -21,3 +23,41 @@ lt --port 8000
Thats it! It will connect to the tunnel server, setup the tunnel, and tell you what url to use for your testing. This url will remain active for the duration of your session; so feel free to share it with others for happy fun time!
You can restart your local server all you want, ```lt``` is smart enough to detect this and reconnect once it is back.
## API ##
The localtunnel client is also usable through an API (for test integration, automation, etc)
```javascript
var localtunnel = require('localtunnel');
var client = localtunnel.connect({
// the localtunnel server
host: 'http://localtunnel.me',
// your local application port
port: 12345
});
// when your are assigned a url
client.on('url', function(url) {
// you can now make http requests to the url
// they will be proxied to your local server on port [12345]
});
client.on('error', function(err) {
// uh oh!
});
```
## other clients ##
Clients in other languages
*go* [gotunnelme](https://github.com/NoahShen/gotunnelme)
## server ##
See shtylman/localtunnel-server for details on the server that powers localtunnel.
## License ##
MIT

39
bin/client Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env node
var lt_client = require('../client');
var argv = require('optimist')
.usage('Usage: $0 --port [num]')
.demand(['port'])
.options('host', {
default: 'http://localtunnel.me',
describe: 'upstream server providing forwarding'
})
.options('subdomain', {
describe: 'request this subdomain'
})
.options('local-host', {
describe: 'tunnel traffic to this host instead of localhost'
})
.default('local-host', 'localhost')
.describe('port', 'internal http server port')
.argv;
var opt = {
host: argv.host,
port: argv.port,
local_host: argv['local-host'],
subdomain: argv.subdomain,
}
var client = lt_client.connect(opt);
// only emitted when the url changes
client.on('url', function(url) {
console.log('your url is: %s', url);
});
client.on('error', function(err) {
console.error(err);
});
// vim: ft=javascript

2
bin/lt
View File

@@ -1,2 +0,0 @@
#!/usr/bin/env node
require(__dirname + '/../client');

286
client.js
View File

@@ -1,95 +1,251 @@
// builtin
var net = require('net');
var url = require('url');
var EventEmitter = require('events').EventEmitter;
var request = require('request');
var debug = require('debug')('localtunnel:client');
var argv = require('optimist')
.usage('Usage: $0 --port [num]')
.demand(['port'])
.options('host', {
default: 'http://localtunnel.me',
describe: 'upstream server providing forwarding'
})
.describe('port', 'internal http server port')
.argv;
// manages groups of tunnels
var TunnelCluster = function(opt) {
if (!(this instanceof TunnelCluster)) {
return new TunnelCluster(opt);
}
// local port
var local_port = argv.port;
var self = this;
self._opt = opt;
// optionally override the upstream server
var upstream = url.parse(argv.host);
// query options
var opt = {
host: upstream.hostname,
port: upstream.port || 80,
path: '/',
json: true
EventEmitter.call(self);
};
var base_uri = 'http://' + opt.host + ':' + opt.port + opt.path;
TunnelCluster.prototype.__proto__ = EventEmitter.prototype;
var internal;
var upstream;
var prev_id;
// establish a new tunnel
TunnelCluster.prototype.open = function() {
var self = this;
(function connect_proxy() {
opt.uri = base_uri + ((prev_id) ? prev_id : '?new');
var opt = self._opt || {};
request(opt, function(err, res, body) {
if (err) {
console.error('upstream not available: %s', err.message);
return process.exit(-1);
var remote_host = opt.remote_host;
var remote_port = opt.remote_port;
var local_host = opt.local_host;
var local_port = opt.local_port;
debug('establishing tunnel %s:%s <> %s:%s', local_host, local_port, remote_host, remote_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);
}
// our assigned hostname and tcp port
var port = body.port;
var host = opt.host;
setTimeout(function() {
self.emit('dead');
}, 1000);
});
// store the id so we can try to get the same one
prev_id = body.id;
function conn_local() {
debug('connecting locally to %s:%d', local_host, local_port);
console.log('your url is: %s', body.url);
if (remote.destroyed) {
self.emit('dead');
return;
}
// connect to remote tcp server
upstream = net.createConnection(port, host);
remote.pause();
// reconnect internal
connect_internal();
// connection to local http server
var local = net.connect({
host: local_host,
port: local_port
});
upstream.on('end', function() {
console.log('> upstream connection terminated');
function remote_close() {
self.emit('dead');
local.unpipe();
local.end();
};
// sever connection to internal server
// on reconnect we will re-establish
internal.end();
remote.once('close', remote_close);
setTimeout(function() {
connect_proxy();
}, 1000);
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();
remote.pipe(local).pipe(remote);
// when local closes, also get a new remote
local.once('close', function(had_error) {
remote.unpipe();
local.unpipe();
remote.end();
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;
};
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 + '/';
// optionally override the upstream server
var upstream = url.parse(opt.host);
// no subdomain at first, maybe use requested domain
var assigned_domain = opt.subdomain;
// where to quest
params.uri = base_uri + ((assigned_domain) ? assigned_domain : '?new');
(function get_url() {
request(params, function(err, res, body) {
if (err) {
// TODO (shtylman) don't print to stdout?
console.log('tunnel server offline: ' + err.message + ', retry 1s');
return setTimeout(get_url, 1000);
}
var port = body.port;
var host = upstream.hostname;
var max_conn = body.max_conn_count || 1;
cb(null, {
remote_host: upstream.hostname,
remote_port: body.port,
name: body.id,
url: body.url,
max_conn: max_conn
});
});
})();
};
Tunnel.prototype._establish = function(info) {
var self = this;
var opt = self._opt;
info.local_host = opt.local_host || 'localhost';
info.local_port = opt.port;
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);
});
});
})();
function connect_internal() {
// when a tunnel dies, open a new one
tunnels.on('dead', function(tunnel) {
tunnel_count--;
debug('tunnel dead [total: %d]', tunnel_count);
internal = net.createConnection(local_port);
internal.on('error', function(err) {
console.log('error connecting to local server. retrying in 1s');
if (self._closed) {
return;
}
setTimeout(function() {
connect_internal();
}, 1000);
tunnels.open();
});
internal.on('end', function() {
console.log('disconnected from local server. retrying in 1s');
setTimeout(function() {
connect_internal();
}, 1000);
// establish as many tunnels as allowed
for (var count = 0 ; count < info.max_conn ; ++count) {
tunnels.open();
}
};
Tunnel.prototype.open = function() {
var self = this;
self._init(function(err, info) {
if (err) {
return self.emit('error', err);
}
self._establish(info);
});
};
upstream.pipe(internal);
internal.pipe(upstream);
}
// shutdown tunnels
Tunnel.prototype.close = function() {
var self = this;
self._closed = true;
self.emit('close');
};
module.exports.connect = function(opt) {
var client = Tunnel(opt);
client.open();
return client;
};

View File

@@ -2,22 +2,19 @@
"author": "Roman Shtylman <shtylman@gmail.com>",
"name": "localtunnel",
"description": "expose localhost to the world",
"version": "0.0.1",
"version": "0.2.0",
"repository": {
"type": "git",
"url": "git://github.com/shtylman/localtunnel.git"
},
"dependencies": {
"request": "2.11.4",
"book": "1.2.0",
"optimist": "0.3.4"
"optimist": "0.3.4",
"debug": "0.7.4"
},
"devDependencies": {},
"optionalDependencies": {},
"engines": {
"node": "*"
},
"bin": {
"lt": "./bin/lt"
}
"lt": "./bin/client"
},
"main": "./client.js"
}

343
server.js
View File

@@ -1,343 +0,0 @@
// builtin
var http = require('http');
var net = require('net');
var url = require('url');
var FreeList = require('freelist').FreeList;
var argv = require('optimist')
.usage('Usage: $0 --port [num]')
.options('port', {
default: '80',
describe: 'listen on this port for outside requests'
})
.argv;
if (argv.help) {
require('optimist').showHelp();
process.exit();
}
// here be dragons
var HTTPParser = process.binding('http_parser').HTTPParser;
var ServerResponse = http.ServerResponse;
var IncomingMessage = http.IncomingMessage;
var log = require('book');
var chars = 'abcdefghiklmnopqrstuvwxyz';
function rand_id() {
var randomstring = '';
for (var i=0; i<4; ++i) {
var rnum = Math.floor(Math.random() * chars.length);
randomstring += chars[rnum];
}
return randomstring;
}
var server = http.createServer();
// id -> client http server
var clients = {};
// id -> list of sockets waiting for a valid response
var wait_list = {};
var parsers = http.parsers;
// data going back to a client (the last client that made a request)
function socketOnData(d, start, end) {
var socket = this;
var req = this._httpMessage;
var current = clients[socket.subdomain].current;
if (!current) {
log.error('no current for http response from backend');
return;
}
// send the goodies
current.write(d.slice(start, end));
// invoke parsing so we know when all the goodies have been sent
var parser = current.out_parser;
parser.socket = socket;
var ret = parser.execute(d, start, end - start);
if (ret instanceof Error) {
debug('parse error');
freeParser(parser, req);
socket.destroy(ret);
}
}
function freeParser(parser, req) {
if (parser) {
parser._headers = [];
parser.onIncoming = null;
if (parser.socket) {
parser.socket.onend = null;
parser.socket.ondata = null;
parser.socket.parser = null;
}
parser.socket = null;
parser.incoming = null;
parsers.free(parser);
parser = null;
}
if (req) {
req.parser = null;
}
}
// single http connection
// gets a single http response back
server.on('connection', function(socket) {
var self = this;
var for_client = false;
var client_id;
var request;
var parser = parsers.alloc();
parser.socket = socket;
parser.reinitialize(HTTPParser.REQUEST);
// a full request is complete
// we wait for the response from the server
parser.onIncoming = function(req, shouldKeepAlive) {
log.trace('request', req.url);
request = req;
for_client = false;
var hostname = req.headers.host;
if (!hostname) {
log.trace('no hostname: %j', req.headers);
// normal processing if not proxy
var res = new ServerResponse(req);
// TODO(shtylman) skip favicon for now, it caused problems
if (req.url === '/favicon.ico') {
return;
}
res.assignSocket(parser.socket);
self.emit('request', req, res);
return;
}
var match = hostname.match(/^([a-z]{4})[.].*/);
if (!match) {
// normal processing if not proxy
var res = new ServerResponse(req);
// TODO(shtylman) skip favicon for now, it caused problems
if (req.url === '/favicon.ico') {
return;
}
res.assignSocket(parser.socket);
self.emit('request', req, res);
return;
}
client_id = match[1];
for_client = true;
var out_parser = parsers.alloc();
out_parser.reinitialize(HTTPParser.RESPONSE);
socket.out_parser = out_parser;
// we have a response
out_parser.onIncoming = function(res) {
res.on('end', function() {
log.trace('done with response for: %s', req.url);
// done with the parser
parsers.free(out_parser);
var next = wait_list[client_id].shift();
clients[client_id].current = next;
if (!next) {
return;
}
// write original bytes that we held cause client was busy
clients[client_id].write(next.queue);
next.resume();
});
};
};
// process new data on the client socket
// we may need to forward this it the backend
socket.ondata = function(d, start, end) {
var ret = parser.execute(d, start, end - start);
// invalid request from the user
if (ret instanceof Error) {
debug('parse error');
socket.destroy(ret);
return;
}
// only write data if previous request to this client is done?
log.trace('%s %s', parser.incoming && parser.incoming.upgrade, for_client);
// what if the subdomains are treated differently
// as individual channels to the backend if available?
// how can I do that?
if (parser.incoming && parser.incoming.upgrade) {
// websocket shit
}
// wtf do you do with upgraded connections?
// forward the data to the backend
if (for_client) {
var client = clients[client_id];
// requesting a subdomain that doesn't exist
if (!client) {
return;
}
// if the client is already processing something
// then new connections need to go into pause mode
// and when they are revived, then they can send data along
if (client.current && client.current !== socket) {
log.trace('pausing', request.url);
// prevent new data from gathering for this connection
// we are waiting for a response to a previous request
socket.pause();
var copy = Buffer(end - start);
d.copy(copy, 0, start, end);
socket.queue = copy;
wait_list[client_id].push(socket);
return;
}
// this socket needs to receive responses
client.current = socket;
// send through tcp tunnel
client.write(d.slice(start, end));
}
};
socket.onend = function() {
var ret = parser.finish();
if (ret instanceof Error) {
log.trace('parse error');
socket.destroy(ret);
return;
}
socket.end();
};
socket.on('close', function() {
parsers.free(parser);
});
});
server.on('request', function(req, res) {
// ignore favicon
if (req.url === '/favicon.ico') {
res.writeHead(404);
return res.end();
}
var parsed = url.parse(req.url, true);
// redirect main page to github reference
if (req.url === '/' && !parsed.query.new) {
res.writeHead(301, { Location: 'http://shtylman.github.com/localtunnel/' });
res.end();
}
var match = req.url.match(/\/([a-z]{4})?/);
// user can request a particular set of characters
// will be given if not already taken
// this is useful when the main server is restarted
// users can keep testing with their expected ids
var requested_id;
if (match && match[1]) {
requested_id = match[1];
}
var id = requested_id || rand_id();
if (wait_list[id]) {
// new id
id = rand_id();
}
// generate new shit for client
if (wait_list[id]) {
wait_list[id].forEach(function(waiting) {
waiting.end();
});
}
var client_server = net.createServer();
client_server.listen(function() {
var port = client_server.address().port;
log.info('tcp server listening on port: %d', port);
var url = 'http://' + id + '.' + req.headers.host;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ url: url, id: id, port: port }));
});
// user has 5 seconds to connect before their slot is given up
var conn_timeout = setTimeout(function() {
client_server.close();
}, 5000);
client_server.on('connection', function(socket) {
// who the info should route back to
socket.subdomain = id;
// multiplexes socket data out to clients
socket.ondata = socketOnData;
clearTimeout(conn_timeout);
log.trace('new connection for id: %s', id);
clients[id] = socket;
wait_list[id] = [];
socket.on('end', function() {
delete clients[id];
});
});
client_server.on('err', function(err) {
log.error(err);
});
});
server.listen(argv.port, function() {
log.info('server listening on port: %d', server.address().port);
});