17 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
11 changed files with 76 additions and 625 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,12 +26,12 @@ 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.me', host: 'http://localtunnel.me',
// your local application port // your local application port
@@ -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,35 +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')({
max_tcp_sockets: 5
});
server.listen(argv.port, function() {
log.info('server listening on port: %d', server.address().port);
});
// vim: ft=javascript

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,7 +34,7 @@ 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) { var tunnel = function (remote_host, remote_port, dead) {
var remote_opt = { var remote_opt = {
host: remote_host, host: remote_host,
@@ -38,7 +42,7 @@ var connect = function(opt) {
}; };
var local_opt = { var local_opt = {
host: 'localhost', host: local_host,
port: local_port port: local_port
}; };
@@ -49,9 +53,9 @@ var connect = function(opt) {
return; return;
} }
// we need a new tunnel
if (++remote_attempts >= 3) { if (++remote_attempts >= 3) {
console.error('localtunnel server offline - try again'); return dead();
process.exit(-1);
} }
// connection to localtunnel server // connection to localtunnel server
@@ -59,7 +63,7 @@ var connect = function(opt) {
remote.once('error', function(err) { remote.once('error', function(err) {
if (err.code !== 'ECONNREFUSED') { if (err.code !== 'ECONNREFUSED') {
local.emit('error', err); remote.emit('error', err);
} }
// retrying connection to local server // retrying connection to local server
@@ -108,35 +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');
// get an id from lt server and setup forwarding tcp connections function init_tunnel() {
request_url(params, function(err, body) { // 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;
var max_conn = body.max_conn_count || 1; // after all our tunnels die, we ask for new ones
for (var count = 0 ; count < max_conn ; ++count) { // this might happen if the upstream server dies
tunnel(host, port); 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;
}; };
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,30 +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.4", "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",
"http-raw": "1.1.0" "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"
} }

324
server.js
View File

@@ -1,324 +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;
// vendor
var log = require('book');
var createRawServer = require('http-raw');
// local
var rand_id = require('./lib/rand_id');
// id -> client http server
var clients = {};
// available parsers
var parsers = http.parsers;
// send this request to the appropriate client
// in -> incoming request stream
function proxy_request(client, req, res, rs, ws) {
rs = rs || req.createRawStream();
ws = ws || res.createRawStream();
// socket is a tcp connection back to the user hosting the site
var sock = client.sockets.shift();
// queue request
if (!sock) {
log.info('no more clients, queued: %s', req.url);
rs.pause();
client.waiting.push([req, res, rs, ws]);
return;
}
log.info('handle req: %s', req.url);
// pipe incoming request into tcp socket
// incoming request isn't allowed to end the socket back to lt client
rs.pipe(sock, { end: false });
sock.ws = ws;
sock.req = req;
// since tcp connection to upstream are kept open
// invoke parsing so we know when the response is complete
var parser = sock.parser;
parser.reinitialize(HTTPParser.RESPONSE);
parser.socket = sock;
// we have completed a response
// the tcp socket is free again
parser.onIncoming = function (res) {
parser.onMessageComplete = function() {
log.info('ended response: %s', req.url);
// any request we had going on is now done
ws.end();
// no more forwarding
delete sock.ws;
delete parser.onIncoming;
// return socket to available pool
client.sockets.push(sock);
var next = client.waiting.shift();
if (next) {
log.trace('popped');
proxy_request(client, next[0], next[1], next[2], next[3]);
}
};
};
rs.resume();
}
function upstream_response(d, start, end) {
var socket = this;
var ws = socket.ws;
if (!ws) {
log.warn('no stream set for req:', socket.req.url);
return;
}
ws.write(d.slice(start, end));
if (socket.upgraded) {
return;
}
var ret = socket.parser.execute(d, start, end - start);
if (ret instanceof Error) {
log.error(ret);
parsers.free(parser);
socket.destroy(ret);
}
}
var handle_req = function (req, res) {
var max_tcp_sockets = req.socket.server.max_tcp_sockets;
// ignore favicon
if (req.url === '/favicon.ico') {
res.writeHead(404);
return res.end();
}
var hostname = req.headers.host;
if (!hostname) {
log.trace('no hostname: %j', req.headers);
return res.end();
}
var match = hostname.match(/^([a-z]{4})[.].*/);
if (match) {
var client_id = match[1];
var client = clients[client_id];
// no such subdomain
if (!client) {
log.trace('no client found for id: ' + client_id);
res.statusCode = 404;
return res.end();
}
return proxy_request(client, req, res);
}
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();
// if the id already exists, this client must use something else
if (clients[id]) {
id = rand_id();
}
// 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
}));
});
var conn_timeout;
// user has 5 seconds to connect before their slot is given up
function maybe_tcp_close() {
conn_timeout = setTimeout(client_server.close.bind(client_server), 5000);
}
maybe_tcp_close();
// no longer accepting connections for this id
client_server.on('close', function() {
log.trace('closed tcp socket for client(%s)', id);
clearTimeout(conn_timeout);
delete clients[id];
});
client_server.on('connection', function(socket) {
// no more socket connections allowed
if (client.sockets.length >= max_tcp_sockets) {
return socket.end();
}
log.trace('new connection for id: %s', id);
// no need to close the client server
clearTimeout(conn_timeout);
// allocate a response parser for the socket
// it only needs one since it will reuse it
socket.parser = parsers.alloc();
socket._orig_ondata = socket.ondata;
socket.ondata = upstream_response;
client.sockets.push(socket);
socket.once('close', function(had_error) {
log.trace('client %s closed socket', id);
// remove this socket
var idx = client.sockets.indexOf(socket);
client.sockets.splice(idx, 1);
log.trace('remaining client sockets: %s', client.sockets.length);
// no more sockets for this ident
if (client.sockets.length === 0) {
log.trace('all client(%s) sockets disconnected', id);
maybe_tcp_close();
}
});
// close will be emitted after this
socket.on('error', function(err) {
log.error(err);
socket.end();
});
});
client_server.on('error', function(err) {
log.error(err);
});
};
var handle_upgrade = function(req, ws) {
if (req.headers.connection !== 'Upgrade') {
return;
}
var hostname = req.headers.host;
if (!hostname) {
return res.end();
}
var match = hostname.match(/^([a-z]{4})[.].*/);
// not a valid client
if (!match) {
return res.end();
}
var client_id = match[1];
var client = clients[client_id];
if (!client) {
// no such subdomain
return res.end();
}
var socket = client.sockets.shift();
if (!socket) {
// no available sockets to upgrade to
return res.end();
}
var stream = req.createRawStream();
socket.ws = ws;
socket.upgraded = true;
stream.once('end', function() {
delete socket.ws;
// when this ends, we just reset the socket to the lt client
// this is easier than trying to figure anything else out
socket.end();
// put socket back into available pool
client.sockets.push(socket);
var next = client.waiting.shift();
if (next) {
log.trace('popped');
proxy_request(client, next[0], next[1], next[2], next[3]);
}
});
stream.pipe(socket, {end: false});
socket.once('end', ws.end.bind(ws));
};
module.exports = function(opt) {
opt = opt || {};
var server = createRawServer();
server.max_tcp_sockets = opt.max_tcp_sockets || 5;
server.on('request', handle_req);
server.on('upgrade', handle_upgrade);
return server;
};

View File

@@ -1,99 +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);
});
});
test('shutdown', function() {
localtunnel_server.close();
});

View File

@@ -1,105 +0,0 @@
var http = require('http');
var url = require('url');
var assert = require('assert');
var localtunnel_server = require('../').server({
max_tcp_sockets: 1
});
var localtunnel_client = require('../').client;
var server;
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) {
server = http.createServer();
server.on('request', function(req, res) {
// respond sometime later
setTimeout(function() {
res.setHeader('x-count', req.headers['x-count']);
res.end('foo');
}, 100);
});
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 count = 0;
var opt = {
host: 'localhost',
port: 3000,
agent: false,
headers: {
host: hostname
},
path: '/'
}
var num_requests = 2;
var responses = 0;
function maybe_done() {
if (++responses >= num_requests) {
done();
}
}
function make_req() {
opt.headers['x-count'] = count++;
http.get(opt, function(res) {
res.setEncoding('utf8');
var body = '';
res.on('data', function(chunk) {
body += chunk;
});
res.on('end', function() {
assert.equal('foo', body);
maybe_done();
});
});
}
for (var i=0 ; i<num_requests ; ++i) {
make_req();
}
});
test('shutdown', function() {
localtunnel_server.close();
});