22 Commits

Author SHA1 Message Date
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
10 changed files with 137 additions and 631 deletions

View File

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

View File

@@ -1,4 +1,4 @@
# localtunnel [![Build Status](https://secure.travis-ci.org/shtylman/localtunnel.png)](http://travis-ci.org/shtylman/localtunnel) # # 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.
@@ -10,6 +10,8 @@ Great for working with browser testing tools like browserling or external api ca
npm install -g localtunnel npm install -g localtunnel
``` ```
This will install the localtunnel module globally and add the 'lt' client cli tool to your PATH.
## use ## ## use ##
Super Easy! 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.
@@ -24,14 +26,14 @@ You can restart your local server all you want, ```lt``` is smart enough to dete
## API ## ## API ##
The localtunnel client is also usable through an API (test integration, automation, etc) The localtunnel client is also usable through an API (for test integration, automation, etc)
```javascript ```javascript
var lt_client = require('localtunnel').client; var localtunnel = require('localtunnel');
var client = lt_client.connect({ var client = localtunnel.connect({
// the localtunnel server // the localtunnel server
host: 'http://localtunnel.com', host: 'http://localtunnel.me',
// your local application port // your local application port
port: 12345 port: 12345
}); });
@@ -46,3 +48,16 @@ client.on('error', function(err) {
// uh oh! // 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

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
var lt_client = require(__dirname + '/../client'); var lt_client = require('../client');
var argv = require('optimist') var argv = require('optimist')
.usage('Usage: $0 --port [num]') .usage('Usage: $0 --port [num]')
@@ -11,12 +11,17 @@ var argv = require('optimist')
.options('subdomain', { .options('subdomain', {
describe: 'request this 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') .describe('port', 'internal http server port')
.argv; .argv;
var opt = { var opt = {
host: argv.host, host: argv.host,
port: argv.port, port: argv.port,
local_host: argv['local-host'],
subdomain: argv.subdomain, subdomain: argv.subdomain,
} }

View File

@@ -1,33 +0,0 @@
#!/usr/bin/env node
// vendor
var log = require('book');
var optimist = require('optimist');
var argv = optimist
.usage('Usage: $0 --port [num]')
.options('port', {
default: '80',
describe: 'listen on this port for outside requests'
})
.argv;
if (argv.help) {
optimist.showHelp();
process.exit();
}
process.once('uncaughtException', function(err) {
log.panic(err);
process.exit(-1);
return;
});
var server = require('../server');
server.listen(argv.port, function() {
log.info('server listening on port: %d', server.address().port);
});
// vim: ft=javascript

183
client.js
View File

@@ -1,14 +1,15 @@
// builtin
var net = require('net'); var net = require('net');
var url = require('url'); var url = require('url');
var request = require('request');
var EventEmitter = require('events').EventEmitter; var EventEmitter = require('events').EventEmitter;
var after = require('after');
var request = require('request');
// request upstream url and connection info // request upstream url and connection info
var request_url = function(params, cb) { var request_url = function(params, cb) {
request(params, function(err, res, body) { request(params, function(err, res, body) {
if (err) { if (err) {
cb(err); return cb(err);
} }
cb(null, body); cb(null, body);
@@ -18,6 +19,9 @@ var request_url = function(params, cb) {
var connect = function(opt) { var connect = function(opt) {
var ev = new EventEmitter(); var ev = new EventEmitter();
// local host
var local_host = opt.local_host;
// local port // local port
var local_port = opt.port; var local_port = opt.port;
@@ -30,24 +34,74 @@ var connect = function(opt) {
var assigned_domain = opt.subdomain; var assigned_domain = opt.subdomain;
// connect to upstream given connection parameters // connect to upstream given connection parameters
var tunnel = function (remote_host, remote_port, max_conn) { var tunnel = function (remote_host, remote_port, dead) {
var count = 0;
// open 5 connections to the localtunnel server var remote_opt = {
// allows for resources to be served faster host: remote_host,
for (var count = 0 ; count < max_conn ; ++count) { port: remote_port
var upstream = duplex(remote_host, remote_port, 'localhost', local_port); };
upstream.once('end', function() {
// all upstream connections have been closed var local_opt = {
if (--count <= 0) { host: local_host,
tunnel(remote_host, remote_port, max_conn); 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);
}); });
upstream.on('error', function(err) { function recon_local() {
console.error(err); 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 = { var params = {
@@ -58,76 +112,49 @@ var connect = function(opt) {
// where to quest // where to quest
params.uri = base_uri + ((assigned_domain) ? assigned_domain : '?new'); params.uri = base_uri + ((assigned_domain) ? assigned_domain : '?new');
request_url(params, function(err, body) { function init_tunnel() {
// get an id from lt server and setup forwarding tcp connections
request_url(params, function(err, body) {
if (err) {
ev.emit('error', new Error('tunnel server not available: ' + err.message + ', retry 1s'));
if (err) { // retry interval for id request
ev.emit('error', new Error('tunnel server not available: %s, retry 1s', err.message)); return setTimeout(function() {
init_tunnel();
}, 1000);
}
// retry interval for id request // our assigned hostname and tcp port
return setTimeout(function() { var port = body.port;
connect_proxy(opt); var host = upstream.hostname;
}, 1000);
}
// our assigned hostname and tcp port // store the id so we can try to get the same one
var port = body.port; assigned_domain = body.id;
var host = upstream.hostname;
// store the id so we can try to get the same one var max_conn = body.max_conn_count || 1;
assigned_domain = body.id;
tunnel(host, port, body.max_conn_count || 1); // after all our tunnels die, we ask for new ones
// this might happen if the upstream server dies
var dead = after(max_conn, function() {
init_tunnel();
});
ev.emit('url', body.url); for (var count = 0 ; count < max_conn ; ++count) {
}); tunnel(host, port, dead);
}
ev.emit('url', body.url);
});
}
init_tunnel();
return ev; return ev;
}; };
var duplex = function(remote_host, remote_port, local_host, local_port) {
var ev = new EventEmitter();
// connect to remote tcp server
var upstream = net.createConnection(remote_port, remote_host);
var internal;
// when upstream connection is closed, close other associated connections
upstream.once('end', function() {
ev.emit('error', new Error('upstream connection terminated'));
// sever connection to internal server
// on reconnect we will re-establish
internal.end();
ev.emit('end');
});
upstream.on('error', function(err) {
ev.emit('error', err);
});
(function connect_internal() {
internal = net.createConnection(local_port, local_host);
internal.on('error', function() {
ev.emit('error', new Error('error connecting to local server. retrying in 1s'));
setTimeout(function() {
connect_internal();
}, 1000);
});
internal.on('end', function() {
ev.emit('error', new Error('disconnected from local server. retrying in 1s'));
setTimeout(function() {
connect_internal();
}, 1000);
});
upstream.pipe(internal).pipe(upstream);
})();
return ev;
}
module.exports.connect = connect; module.exports.connect = connect;
// for backwards compatibility
// old localtunnel modules had server and client code in same module
// so to keep .client working we expose it here
module.exports.client = module.exports;

View File

@@ -1,2 +0,0 @@
module.exports.client = require('./client');
module.exports.server = require('./server');

View File

@@ -1,12 +0,0 @@
var chars = 'abcdefghiklmnopqrstuvwxyz';
module.exports = 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;
}

View File

@@ -2,29 +2,19 @@
"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.0.3", "version": "0.1.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git://github.com/shtylman/localtunnel.git" "url": "git://github.com/shtylman/localtunnel.git"
}, },
"dependencies": { "dependencies": {
"request": "2.11.4", "request": "2.11.4",
"book": "1.2.0", "optimist": "0.3.4",
"optimist": "0.3.4" "after": "0.8.1"
},
"devDependencies": {
"mocha": "1.6.0"
},
"optionalDependencies": {},
"engines": {
"node": "*"
},
"scripts": {
"test": "mocha --ui qunit -- test",
"start": "./bin/server"
}, },
"devDependencies": {},
"bin": { "bin": {
"lt": "./bin/client" "lt": "./bin/client"
}, },
"main": "./index.js" "main": "./client.js"
} }

390
server.js
View File

@@ -1,390 +0,0 @@
// builtin
var http = require('http');
var net = require('net');
var url = require('url');
// here be dragons
var HTTPParser = process.binding('http_parser').HTTPParser;
var ServerResponse = http.ServerResponse;
var IncomingMessage = http.IncomingMessage;
// vendor
var log = require('book');
// local
var rand_id = require('./lib/rand_id');
var server = http.createServer();
// id -> client http server
var clients = {};
// available parsers
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 response_socket = socket.respond_socket;
if (!response_socket) {
log.error('no response socket assigned for http response from backend');
return;
}
// pass the response from our client back to the requesting socket
response_socket.write(d.slice(start, end));
if (socket.for_websocket) {
return;
}
// invoke parsing so we know when the response is complete
var parser = response_socket.out_parser;
parser.socket = socket;
var ret = parser.execute(d, start, end - start);
if (ret instanceof Error) {
log.error(ret);
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;
// parser handles incoming requests for the socket
// the request is what lets us know if we proxy or not
var parser = parsers.alloc();
parser.socket = socket;
parser.reinitialize(HTTPParser.REQUEST);
function our_request(req) {
var res = new ServerResponse(req);
res.assignSocket(socket);
self.emit('request', req, res);
return;
}
// a full request is complete
// we wait for the response from the server
parser.onIncoming = function(req, shouldKeepAlive) {
log.trace('request', req.url);
// default is that the data is not for the client
delete parser.sock;
delete parser.buffer;
delete parser.client;
var hostname = req.headers.host;
if (!hostname) {
log.trace('no hostname: %j', req.headers);
return our_request(req);
}
var match = hostname.match(/^([a-z]{4})[.].*/);
if (!match) {
return our_request(req);
}
var client_id = match[1];
var client = clients[client_id];
// requesting a subdomain that doesn't exist
if (!client) {
return socket.end();
}
parser.client = client;
// assigned socket for the client
var sock = client.sockets.shift();
// no free sockets, queue
if (!sock) {
parser.buffer = true;
return;
}
// for tcp proxying
parser.sock = sock;
// set who we will respond back to
sock.respond_socket = socket;
var out_parser = parsers.alloc();
out_parser.reinitialize(HTTPParser.RESPONSE);
socket.out_parser = out_parser;
// we have completed a response
// the tcp socket is free again
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);
// unset the response
delete sock.respond_socket;
var next = client.waiting.shift();
if (!next) {
// return socket to available
client.sockets.push(sock);
return;
}
// reuse avail socket for next connection
sock.respond_socket = next;
// needed to know when this response will be done
out_parser.reinitialize(HTTPParser.RESPONSE);
next.out_parser = out_parser;
// write original bytes we held cause we were busy
sock.write(next.queue);
// continue with other bytes
next.resume();
return;
});
};
};
// process new data on the client socket
// we may need to forward this it the backend
socket.ondata = function(d, start, end) {
// run through request parser to determine if we should pass to tcp
// onIncoming will be run before this returns
var ret = parser.execute(d, start, end - start);
// invalid request from the user
if (ret instanceof Error) {
log.error(ret);
socket.destroy(ret);
return;
}
// websocket stuff
if (parser.incoming && parser.incoming.upgrade) {
log.trace('upgrade request');
parser.finish();
var hostname = parser.incoming.headers.host;
var match = hostname.match(/^([a-z]{4})[.].*/);
if (!match) {
return our_request(req);
}
var client_id = match[1];
var client = clients[client_id];
var sock = client.sockets.shift();
sock.respond_socket = socket;
sock.for_websocket = true;
socket.ondata = function(d, start, end) {
sock.write(d.slice(start, end));
};
socket.end = function() {
log.trace('websocket end');
delete sock.respond_socket;
client.sockets.push(sock);
}
sock.write(d.slice(start, end));
return;
}
// if no available socket, buffer the request for later
if (parser.buffer) {
// pause any further data on this socket
socket.pause();
// copy the current data since we have already received it
var copy = Buffer(end - start);
d.copy(copy, 0, start, end);
socket.queue = copy;
// add socket to queue
parser.client.waiting.push(socket);
return;
}
if (!parser.sock) {
return;
}
// assert, respond socket should be set
// send through tcp tunnel
// responses will go back to the respond_socket
parser.sock.write(d.slice(start, end));
};
socket.onend = function() {
var ret = parser.finish();
if (ret instanceof Error) {
log.error(ret);
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();
return;
}
// at this point, the client is requesting a new tunnel setup
// either generate an id or use the one they requested
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();
// maximum number of tcp connections the client can setup
// each tcp channel allows for more parallel requests
var max_tcp_sockets = 4;
// sockets is a list of available sockets for the connection
// waiting is?
var client = clients[id] = {
sockets: [],
waiting: []
};
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,
max_conn_count: max_tcp_sockets
}));
});
// user has 5 seconds to connect before their slot is given up
var conn_timeout = setTimeout(function() {
client_server.close();
}, 5000);
// no longer accepting connections for this id
client_server.on('close', function() {
delete clients[id];
});
var count = 0;
client_server.on('connection', function(socket) {
// no more socket connections allowed
if (count++ >= max_tcp_sockets) {
return socket.end();
}
log.trace('new connection for id: %s', id);
// multiplexes socket data out to clients
socket.ondata = socketOnData;
// no need to close the client server
clearTimeout(conn_timeout);
// add socket to pool for this id
var idx = client.sockets.push(socket) - 1;
socket.on('close', function(had_error) {
count--;
client.sockets.splice(idx, 1);
// no more sockets for this ident
if (client.sockets.length === 0) {
delete clients[id];
}
});
// close will be emitted after this
socket.on('error', function(err) {
log.error(err);
});
});
client_server.on('error', function(err) {
log.error(err);
});
});
module.exports = server;

95
test.js
View File

@@ -1,95 +0,0 @@
var http = require('http');
var url = require('url');
var assert = require('assert');
var localtunnel_server = require('./').server;
var localtunnel_client = require('./').client;
test('setup localtunnel server', function(done) {
localtunnel_server.listen(3000, function() {
console.log('lt server on:', 3000);
done();
});
});
test('setup local http server', function(done) {
var server = http.createServer();
server.on('request', function(req, res) {
res.write('foo');
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) {
var client = localtunnel_client.connect({
host: 'http://localhost:' + 3000,
port: test._fake_port
});
client.on('url', function(url) {
assert.ok(/^http:\/\/.*localhost:3000$/.test(url));
test._fake_url = url;
done();
});
client.on('error', function(err) {
console.error(err);
});
});
test('query localtunnel server w/ ident', function(done) {
var uri = test._fake_url;
var hostname = url.parse(uri).hostname;
var opt = {
host: 'localhost',
port: 3000,
headers: {
host: hostname
},
path: '/'
}
var req = http.request(opt, function(res) {
res.setEncoding('utf8');
var body = '';
res.on('data', function(chunk) {
body += chunk;
});
res.on('end', function() {
assert.equal('foo', body);
// TODO(shtylman) shutdown client
done();
});
});
req.end();
});
test('request specific domain', function(done) {
var client = localtunnel_client.connect({
host: 'http://localhost:' + 3000,
port: test._fake_port,
subdomain: 'abcd'
});
client.on('url', function(url) {
assert.ok(/^http:\/\/abcd.localhost:3000$/.test(url));
done();
});
client.on('error', function(err) {
console.error(err);
});
});