1 Commits

Author SHA1 Message Date
Roman Shtylman
60813d6c04 wip 2016-01-13 22:12:46 -08:00
26 changed files with 1006 additions and 2223 deletions

1
.npmrc
View File

@@ -1 +0,0 @@
save-exact = true

View File

@@ -1,4 +1,4 @@
language: node_js language: node_js
sudo: false sudo: false
node_js: node_js:
- "9.2" - "4"

View File

@@ -1,13 +1,16 @@
FROM node:10.1.0-alpine FROM mhart/alpine-node:4.2.1
RUN mkdir -p /app
WORKDIR /app WORKDIR /app
COPY package.json /app/ ADD package.json /app/
COPY yarn.lock /app/
RUN yarn install --production && yarn cache clean RUN apk add --update make git g++ python && \
npm install --production && \
apk del git make g++ python && \
rm -rf /tmp/* /root/.npm /root/.node-gyp
COPY . /app ADD . /app
ENV NODE_ENV production ENV NODE_ENV production
ENTRYPOINT ["node", "-r", "esm", "./bin/server"] ENTRYPOINT ["bin/server"]

View File

@@ -29,8 +29,6 @@ bin/server --port 1234
The localtunnel server is now running and waiting for client requests on port 1234. You will most likely want to set up a reverse proxy to listen on port 80 (or start localtunnel on port 80 directly). The localtunnel server is now running and waiting for client requests on port 1234. You will most likely want to set up a reverse proxy to listen on port 80 (or start localtunnel on port 80 directly).
**NOTE** By default, localtunnel will use subdomains for clients, if you plan to host your localtunnel server itself on a subdomain you will need to use the _--domain_ option and specify the domain name behind which you are hosting localtunnel. (i.e. my-localtunnel-server.example.com)
#### use your server #### use your server
You can now use your domain with the `--host` flag for the `lt` client. You can now use your domain with the `--host` flag for the `lt` client.
@@ -39,32 +37,6 @@ You can now use your domain with the `--host` flag for the `lt` client.
lt --host http://sub.example.tld:1234 --port 9000 lt --host http://sub.example.tld:1234 --port 9000
``` ```
You will be assigned a URL similar to `heavy-puma-9.sub.example.com:1234`. You will be assigned a URL similar to `qdci.sub.example.com:1234`.
If your server is acting as a reverse proxy (i.e. nginx) and is able to listen on port 80, then you do not need the `:1234` part of the hostname for the `lt` client. If your server is acting as a reverse proxy (i.e. nginx) and is able to listen on port 80, then you do not need the `:1234` part of the hostname for the `lt` client.
## REST API
### POST /api/tunnels
Create a new tunnel. A LocalTunnel client posts to this enpoint to request a new tunnel with a specific name or a randomly assigned name.
### GET /api/status
General server information.
## Deploy
You can deploy your own localtunnel server using the prebuilt docker image.
**Note** This assumes that you have a proxy in front of the server to handle the http(s) requests and forward them to the localtunnel server on port 3000. You can use our [localtunnel-nginx](https://github.com/localtunnel/nginx) to accomplish this.
If you do not want ssl support for your own tunnel (not recommended), then you can just run the below with `--port 80` instead.
```
docker run -d \
--restart always \
--name localtunnel \
--net host \
defunctzombie/localtunnel-server:latest --port 3000
```

View File

@@ -1,16 +1,12 @@
#!/usr/bin/env node -r esm #!/usr/bin/env node
import 'localenv'; require('stackup');
import optimist from 'optimist'; var log = require('bookrc');
var localenv = require('localenv');
var debug = require('debug')('localtunnel');
var optimist = require('optimist');
import log from 'book'; var argv = optimist
import Debug from 'debug';
import CreateServer from '../server';
const debug = Debug('localtunnel');
const argv = optimist
.usage('Usage: $0 --port [num]') .usage('Usage: $0 --port [num]')
.options('secure', { .options('secure', {
default: false, default: false,
@@ -20,13 +16,6 @@ const argv = optimist
default: '80', default: '80',
describe: 'listen on this port for outside requests' describe: 'listen on this port for outside requests'
}) })
.options('address', {
default: '0.0.0.0',
describe: 'IP address to bind to'
})
.options('domain', {
describe: 'Specify the base domain name. This is optional if hosting localtunnel from a regular example.com domain. This is required if hosting a localtunnel server from a subdomain (i.e. lt.example.dom where clients will be client-app.lt.example.come)',
})
.options('max-sockets', { .options('max-sockets', {
default: 10, default: 10,
describe: 'maximum number of tcp sockets each client is allowed to establish at one time (the tunnels)' describe: 'maximum number of tcp sockets each client is allowed to establish at one time (the tunnels)'
@@ -38,31 +27,14 @@ if (argv.help) {
process.exit(); process.exit();
} }
const server = CreateServer({ var server = require('../server')({
max_tcp_sockets: argv['max-sockets'], max_tcp_sockets: argv['max-sockets'],
secure: argv.secure, secure: argv.secure
domain: argv.domain,
}); });
server.listen(argv.port, argv.address, () => { server.listen(argv.port, function() {
debug('server listening on port: %d', server.address().port); debug('server listening on port: %d', server.address().port);
}); });
process.on('SIGINT', () => {
process.exit();
});
process.on('SIGTERM', () => {
process.exit();
});
process.on('uncaughtException', (err) => {
log.error(err);
});
process.on('unhandledRejection', (reason, promise) => {
log.error(reason);
});
// vim: ft=javascript // vim: ft=javascript

13
bookrc.js Normal file
View File

@@ -0,0 +1,13 @@
/// bookrc logging setup
var log = require('book').default();
//log.use(require('book-git')(__dirname));
log.use(require('book-raven')(process.env.SENTRY_DSN));
process.on('uncaughtException', function(err) {
log.error(err);
process.exit(1);
});
module.exports = log;

33
devops/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,33 @@
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 10240;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log off;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
gzip on;
gzip_min_length 1000;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites/*;
}

View File

@@ -0,0 +1,65 @@
proxy_http_version 1.1;
# http://nginx.org/en/docs/http/websocket.html
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream lt-server {
server 127.0.0.1:2000;
}
server {
listen 80 default_server;
listen [::]:80 default_server ipv6only=on;
server_name .localtunnel.me;
location / {
proxy_pass http://lt-server/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto http;
proxy_set_header X-NginX-Proxy true;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_redirect off;
}
}
server {
listen 443 default_server ssl spdy;
listen [::]:443 default_server ipv6only=on;
server_name .localtunnel.me;
ssl on;
ssl_certificate /etc/nginx/ssl/STAR.localtunnel.me.crt;
ssl_certificate_key /etc/nginx/ssl/STAR.localtunnel.me.key;
ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers RC4:HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
location / {
proxy_pass http://lt-server/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header X-NginX-Proxy true;
proxy_set_header Connection $connection_upgrade;
proxy_redirect off;
}
}

5
devops/run.sh Normal file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
docker run --restart always -m 350m --name localtunnel -d --net host defunctzombie/localtunnel-server:0.0.5 bin/server --secure --port 2000 --max-sockets 5
docker run --restart always --name nginx -d --net host -v /home/core/nginx/nginx.conf:/etc/nginx/nginx.conf -v /home/core/nginx/sites:/etc/nginx/sites -v /home/core/nginx/ssl:/etc/nginx/ssl nginx:1.7.8

23
lib/BindingAgent.js Normal file
View File

@@ -0,0 +1,23 @@
var http = require('http');
var util = require('util');
var assert = require('assert');
// binding agent will return a given options.socket as the socket for the agent
// this is useful if you already have a socket established and want the request
// to use that socket instead of making a new one
function BindingAgent(options) {
options = options || {};
http.Agent.call(this, options);
this.socket = options.socket;
assert(this.socket, 'socket is required for BindingAgent');
this.createConnection = create_connection;
}
util.inherits(BindingAgent, http.Agent);
function create_connection(port, host, options) {
return this.socket;
}
module.exports = BindingAgent;

View File

@@ -1,132 +0,0 @@
import http from 'http';
import Debug from 'debug';
import pump from 'pump';
import EventEmitter from 'events';
// A client encapsulates req/res handling using an agent
//
// If an agent is destroyed, the request handling will error
// The caller is responsible for handling a failed request
class Client extends EventEmitter {
constructor(options) {
super();
const agent = this.agent = options.agent;
const id = this.id = options.id;
this.debug = Debug(`lt:Client[${this.id}]`);
// client is given a grace period in which they can connect before they are _removed_
this.graceTimeout = setTimeout(() => {
this.close();
}, 1000).unref();
agent.on('online', () => {
this.debug('client online %s', id);
clearTimeout(this.graceTimeout);
});
agent.on('offline', () => {
this.debug('client offline %s', id);
// if there was a previous timeout set, we don't want to double trigger
clearTimeout(this.graceTimeout);
// client is given a grace period in which they can re-connect before they are _removed_
this.graceTimeout = setTimeout(() => {
this.close();
}, 1000).unref();
});
// TODO(roman): an agent error removes the client, the user needs to re-connect?
// how does a user realize they need to re-connect vs some random client being assigned same port?
agent.once('error', (err) => {
this.close();
});
}
stats() {
return this.agent.stats();
}
close() {
clearTimeout(this.graceTimeout);
this.agent.destroy();
this.emit('close');
}
handleRequest(req, res) {
this.debug('> %s', req.url);
const opt = {
path: req.url,
agent: this.agent,
method: req.method,
headers: req.headers
};
const clientReq = http.request(opt, (clientRes) => {
this.debug('< %s', req.url);
// write response code and headers
res.writeHead(clientRes.statusCode, clientRes.headers);
// using pump is deliberate - see the pump docs for why
pump(clientRes, res);
});
// this can happen when underlying agent produces an error
// in our case we 504 gateway error this?
// if we have already sent headers?
clientReq.once('error', (err) => {
// TODO(roman): if headers not sent - respond with gateway unavailable
});
// using pump is deliberate - see the pump docs for why
pump(req, clientReq);
}
handleUpgrade(req, socket) {
this.debug('> [up] %s', req.url);
socket.once('error', (err) => {
// These client side errors can happen if the client dies while we are reading
// We don't need to surface these in our logs.
if (err.code == 'ECONNRESET' || err.code == 'ETIMEDOUT') {
return;
}
console.error(err);
});
this.agent.createConnection({}, (err, conn) => {
this.debug('< [up] %s', req.url);
// any errors getting a connection mean we cannot service this request
if (err) {
socket.end();
return;
}
// socket met have disconnected while we waiting for a socket
if (!socket.readable || !socket.writable) {
conn.destroy();
socket.end();
return;
}
// websocket requests are special in that we simply re-create the header info
// then directly pipe the socket data
// avoids having to rebuild the request and handle upgrades via the http client
const arr = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
for (let i=0 ; i < (req.rawHeaders.length-1) ; i+=2) {
arr.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i+1]}`);
}
arr.push('');
arr.push('');
// using pump is deliberate - see the pump docs for why
pump(conn, socket);
pump(socket, conn);
conn.write(arr.join('\r\n'));
});
}
}
export default Client;

View File

@@ -1,157 +0,0 @@
import assert from 'assert';
import http from 'http';
import { Duplex } from 'stream';
import WebSocket from 'ws';
import net from 'net';
import Client from './Client';
class DummySocket extends Duplex {
constructor(options) {
super(options);
}
_write(chunk, encoding, callback) {
callback();
}
_read(size) {
this.push('HTTP/1.1 304 Not Modified\r\nX-Powered-By: dummy\r\n\r\n\r\n');
this.push(null);
}
}
class DummyWebsocket extends Duplex {
constructor(options) {
super(options);
this.sentHeader = false;
}
_write(chunk, encoding, callback) {
const str = chunk.toString();
// if chunk contains `GET / HTTP/1.1` -> queue headers
// otherwise echo back received data
if (str.indexOf('GET / HTTP/1.1') === 0) {
const arr = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
];
this.push(arr.join('\r\n'));
this.push('\r\n\r\n');
}
else {
this.push(str);
}
callback();
}
_read(size) {
// nothing to implement
}
}
class DummyAgent extends http.Agent {
constructor() {
super();
}
createConnection(options, cb) {
cb(null, new DummySocket());
}
}
describe('Client', () => {
it('should handle request', async () => {
const agent = new DummyAgent();
const client = new Client({ agent });
const server = http.createServer((req, res) => {
client.handleRequest(req, res);
});
await new Promise(resolve => server.listen(resolve));
const address = server.address();
const opt = {
host: 'localhost',
port: address.port,
path: '/',
};
const res = await new Promise((resolve) => {
const req = http.get(opt, (res) => {
resolve(res);
});
req.end();
});
assert.equal(res.headers['x-powered-by'], 'dummy');
server.close();
});
it('should handle upgrade', async () => {
// need a websocket server and a socket for it
class DummyWebsocketAgent extends http.Agent {
constructor() {
super();
}
createConnection(options, cb) {
cb(null, new DummyWebsocket());
}
}
const agent = new DummyWebsocketAgent();
const client = new Client({ agent });
const server = http.createServer();
server.on('upgrade', (req, socket, head) => {
client.handleUpgrade(req, socket);
});
await new Promise(resolve => server.listen(resolve));
const address = server.address();
const netClient = await new Promise((resolve) => {
const newClient = net.createConnection({ port: address.port }, () => {
resolve(newClient);
});
});
const out = [
'GET / HTTP/1.1',
'Connection: Upgrade',
'Upgrade: websocket'
];
netClient.write(out.join('\r\n') + '\r\n\r\n');
{
const data = await new Promise((resolve) => {
netClient.once('data', (chunk) => {
resolve(chunk.toString());
});
});
const exp = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
];
assert.equal(exp.join('\r\n') + '\r\n\r\n', data);
}
{
netClient.write('foobar');
const data = await new Promise((resolve) => {
netClient.once('data', (chunk) => {
resolve(chunk.toString());
});
});
assert.equal('foobar', data);
}
netClient.destroy();
server.close();
});
});

View File

@@ -1,96 +0,0 @@
import { hri } from 'human-readable-ids';
import Debug from 'debug';
import Client from './Client';
import TunnelAgent from './TunnelAgent';
// Manage sets of clients
//
// A client is a "user session" established to service a remote localtunnel client
class ClientManager {
constructor(opt) {
this.opt = opt || {};
// id -> client instance
this.clients = new Map();
// statistics
this.stats = {
tunnels: 0
};
this.debug = Debug('lt:ClientManager');
// This is totally wrong :facepalm: this needs to be per-client...
this.graceTimeout = null;
}
// create a new tunnel with `id`
// if the id is already used, a random id is assigned
// if the tunnel could not be created, throws an error
async newClient(id) {
const clients = this.clients;
const stats = this.stats;
// can't ask for id already is use
if (clients[id]) {
id = hri.random();
}
const maxSockets = this.opt.max_tcp_sockets;
const agent = new TunnelAgent({
clientId: id,
maxSockets: 10,
});
const client = new Client({
id,
agent,
});
// add to clients map immediately
// avoiding races with other clients requesting same id
clients[id] = client;
client.once('close', () => {
this.removeClient(id);
});
// try/catch used here to remove client id
try {
const info = await agent.listen();
++stats.tunnels;
return {
id: id,
port: info.port,
max_conn_count: maxSockets,
};
}
catch (err) {
this.removeClient(id);
// rethrow error for upstream to handle
throw err;
}
}
removeClient(id) {
this.debug('removing client: %s', id);
const client = this.clients[id];
if (!client) {
return;
}
--this.stats.tunnels;
delete this.clients[id];
client.close();
}
hasClient(id) {
return !!this.clients[id];
}
getClient(id) {
return this.clients[id];
}
}
export default ClientManager;

View File

@@ -1,90 +0,0 @@
import assert from 'assert';
import net from 'net';
import ClientManager from './ClientManager';
describe('ClientManager', () => {
it('should construct with no tunnels', () => {
const manager = new ClientManager();
assert.equal(manager.stats.tunnels, 0);
});
it('should create a new client with random id', async () => {
const manager = new ClientManager();
const client = await manager.newClient();
assert(manager.hasClient(client.id));
manager.removeClient(client.id);
});
it('should create a new client with id', async () => {
const manager = new ClientManager();
const client = await manager.newClient('foobar');
assert(manager.hasClient('foobar'));
manager.removeClient('foobar');
});
it('should create a new client with random id if previous exists', async () => {
const manager = new ClientManager();
const clientA = await manager.newClient('foobar');
const clientB = await manager.newClient('foobar');
assert(clientA.id, 'foobar');
assert(manager.hasClient(clientB.id));
assert(clientB.id != clientA.id);
manager.removeClient(clientB.id);
manager.removeClient('foobar');
});
it('should remove client once it goes offline', async () => {
const manager = new ClientManager();
const client = await manager.newClient('foobar');
const socket = await new Promise((resolve) => {
const netClient = net.createConnection({ port: client.port }, () => {
resolve(netClient);
});
});
const closePromise = new Promise(resolve => socket.once('close', resolve));
socket.end();
await closePromise;
// should still have client - grace period has not expired
assert(manager.hasClient('foobar'));
// wait past grace period (1s)
await new Promise(resolve => setTimeout(resolve, 1500));
assert(!manager.hasClient('foobar'));
}).timeout(5000);
it('should remove correct client once it goes offline', async () => {
const manager = new ClientManager();
const clientFoo = await manager.newClient('foo');
const clientBar = await manager.newClient('bar');
const socket = await new Promise((resolve) => {
const netClient = net.createConnection({ port: clientFoo.port }, () => {
resolve(netClient);
});
});
await new Promise(resolve => setTimeout(resolve, 1500));
// foo should still be ok
assert(manager.hasClient('foo'));
// clientBar shound be removed - nothing connected to it
assert(!manager.hasClient('bar'));
manager.removeClient('foo');
socket.end();
}).timeout(5000);
it('should remove clients if they do not connect within 5 seconds', async () => {
const manager = new ClientManager();
const clientFoo = await manager.newClient('foo');
assert(manager.hasClient('foo'));
// wait past grace period (1s)
await new Promise(resolve => setTimeout(resolve, 1500));
assert(!manager.hasClient('foo'));
}).timeout(5000);
});

View File

@@ -1,175 +0,0 @@
import { Agent } from 'http';
import net from 'net';
import assert from 'assert';
import log from 'book';
import Debug from 'debug';
const DEFAULT_MAX_SOCKETS = 10;
// Implements an http.Agent interface to a pool of tunnel sockets
// A tunnel socket is a connection _from_ a client that will
// service http requests. This agent is usable wherever one can use an http.Agent
class TunnelAgent extends Agent {
constructor(options = {}) {
super({
keepAlive: true,
// only allow keepalive to hold on to one socket
// this prevents it from holding on to all the sockets so they can be used for upgrades
maxFreeSockets: 1,
});
// sockets we can hand out via createConnection
this.availableSockets = [];
// when a createConnection cannot return a socket, it goes into a queue
// once a socket is available it is handed out to the next callback
this.waitingCreateConn = [];
this.debug = Debug(`lt:TunnelAgent[${options.clientId}]`);
// track maximum allowed sockets
this.connectedSockets = 0;
this.maxTcpSockets = options.maxTcpSockets || DEFAULT_MAX_SOCKETS;
// new tcp server to service requests for this client
this.server = net.createServer();
// flag to avoid double starts
this.started = false;
this.closed = false;
}
stats() {
return {
connectedSockets: this.connectedSockets,
};
}
listen() {
const server = this.server;
if (this.started) {
throw new Error('already started');
}
this.started = true;
server.on('close', this._onClose.bind(this));
server.on('connection', this._onConnection.bind(this));
server.on('error', (err) => {
// These errors happen from killed connections, we don't worry about them
if (err.code == 'ECONNRESET' || err.code == 'ETIMEDOUT') {
return;
}
log.error(err);
});
return new Promise((resolve) => {
server.listen(() => {
const port = server.address().port;
this.debug('tcp server listening on port: %d', port);
resolve({
// port for lt client tcp connections
port: port,
});
});
});
}
_onClose() {
this.closed = true;
this.debug('closed tcp socket');
// flush any waiting connections
for (const conn of this.waitingCreateConn) {
conn(new Error('closed'), null);
}
this.waitingCreateConn = [];
this.emit('end');
}
// new socket connection from client for tunneling requests to client
_onConnection(socket) {
// no more socket connections allowed
if (this.connectedSockets >= this.maxTcpSockets) {
this.debug('no more sockets allowed');
socket.destroy();
return false;
}
socket.once('close', (hadError) => {
this.debug('closed socket (error: %s)', hadError);
this.connectedSockets -= 1;
// remove the socket from available list
const idx = this.availableSockets.indexOf(socket);
if (idx >= 0) {
this.availableSockets.splice(idx, 1);
}
this.debug('connected sockets: %s', this.connectedSockets);
if (this.connectedSockets <= 0) {
this.debug('all sockets disconnected');
this.emit('offline');
}
});
// close will be emitted after this
socket.once('error', (err) => {
// we do not log these errors, sessions can drop from clients for many reasons
// these are not actionable errors for our server
socket.destroy();
});
if (this.connectedSockets === 0) {
this.emit('online');
}
this.connectedSockets += 1;
this.debug('new connection from: %s:%s', socket.address().address, socket.address().port);
// if there are queued callbacks, give this socket now and don't queue into available
const fn = this.waitingCreateConn.shift();
if (fn) {
this.debug('giving socket to queued conn request');
setTimeout(() => {
fn(null, socket);
}, 0);
return;
}
// make socket available for those waiting on sockets
this.availableSockets.push(socket);
}
// fetch a socket from the available socket pool for the agent
// if no socket is available, queue
// cb(err, socket)
createConnection(options, cb) {
if (this.closed) {
cb(new Error('closed'));
return;
}
this.debug('create connection');
// socket is a tcp connection back to the user hosting the site
const sock = this.availableSockets.shift();
// no available sockets
// wait until we have one
if (!sock) {
this.waitingCreateConn.push(cb);
this.debug('waiting connected: %s', this.connectedSockets);
this.debug('waiting available: %s', this.availableSockets.length);
return;
}
this.debug('socket given');
cb(null, sock);
}
destroy() {
this.server.close();
super.destroy();
}
}
export default TunnelAgent;

View File

@@ -1,183 +0,0 @@
import http from 'http';
import net from 'net';
import assert from 'assert';
import TunnelAgent from './TunnelAgent';
describe('TunnelAgent', () => {
it('should create an empty agent', async () => {
const agent = new TunnelAgent();
assert.equal(agent.started, false);
const info = await agent.listen();
assert.ok(info.port > 0);
agent.destroy();
});
it('should create a new server and accept connections', async () => {
const agent = new TunnelAgent();
assert.equal(agent.started, false);
const info = await agent.listen();
const sock = net.createConnection({ port: info.port });
// in this test we wait for the socket to be connected
await new Promise(resolve => sock.once('connect', resolve));
const agentSock = await new Promise((resolve, reject) => {
agent.createConnection({}, (err, sock) => {
if (err) {
reject(err);
}
resolve(sock);
});
});
agentSock.write('foo');
await new Promise(resolve => sock.once('readable', resolve));
assert.equal('foo', sock.read().toString());
agent.destroy();
sock.destroy();
});
it('should reject connections over the max', async () => {
const agent = new TunnelAgent({
maxTcpSockets: 2,
});
assert.equal(agent.started, false);
const info = await agent.listen();
const sock1 = net.createConnection({ port: info.port });
const sock2 = net.createConnection({ port: info.port });
// two valid socket connections
const p1 = new Promise(resolve => sock1.once('connect', resolve));
const p2 = new Promise(resolve => sock2.once('connect', resolve));
await Promise.all([p1, p2]);
const sock3 = net.createConnection({ port: info.port });
const p3 = await new Promise(resolve => sock3.once('close', resolve));
agent.destroy();
sock1.destroy();
sock2.destroy();
sock3.destroy();
});
it('should queue createConnection requests', async () => {
const agent = new TunnelAgent();
assert.equal(agent.started, false);
const info = await agent.listen();
// create a promise for the next connection
let fulfilled = false;
const waitSockPromise = new Promise((resolve, reject) => {
agent.createConnection({}, (err, sock) => {
fulfilled = true;
if (err) {
reject(err);
}
resolve(sock);
});
});
// check that the next socket is not yet available
await new Promise(resolve => setTimeout(resolve, 500));
assert(!fulfilled);
// connect, this will make a socket available
const sock = net.createConnection({ port: info.port });
await new Promise(resolve => sock.once('connect', resolve));
const anotherAgentSock = await waitSockPromise;
agent.destroy();
sock.destroy();
});
it('should should emit a online event when a socket connects', async () => {
const agent = new TunnelAgent();
const info = await agent.listen();
const onlinePromise = new Promise(resolve => agent.once('online', resolve));
const sock = net.createConnection({ port: info.port });
await new Promise(resolve => sock.once('connect', resolve));
await onlinePromise;
agent.destroy();
sock.destroy();
});
it('should emit offline event when socket disconnects', async () => {
const agent = new TunnelAgent();
const info = await agent.listen();
const offlinePromise = new Promise(resolve => agent.once('offline', resolve));
const sock = net.createConnection({ port: info.port });
await new Promise(resolve => sock.once('connect', resolve));
sock.end();
await offlinePromise;
agent.destroy();
sock.destroy();
});
it('should emit offline event only when last socket disconnects', async () => {
const agent = new TunnelAgent();
const info = await agent.listen();
const offlinePromise = new Promise(resolve => agent.once('offline', resolve));
const sockA = net.createConnection({ port: info.port });
await new Promise(resolve => sockA.once('connect', resolve));
const sockB = net.createConnection({ port: info.port });
await new Promise(resolve => sockB.once('connect', resolve));
sockA.end();
const timeout = new Promise(resolve => setTimeout(resolve, 500));
await Promise.race([offlinePromise, timeout]);
sockB.end();
await offlinePromise;
agent.destroy();
});
it('should error an http request', async () => {
class ErrorAgent extends http.Agent {
constructor() {
super();
}
createConnection(options, cb) {
cb(new Error('foo'));
}
}
const agent = new ErrorAgent();
const opt = {
host: 'localhost',
port: 1234,
path: '/',
agent: agent,
};
const err = await new Promise((resolve) => {
const req = http.get(opt, (res) => {});
req.once('error', resolve);
});
assert.equal(err.message, 'foo');
});
it('should return stats', async () => {
const agent = new TunnelAgent();
assert.deepEqual(agent.stats(), {
connectedSockets: 0,
});
});
});

13
lib/rand_id.js Normal file
View File

@@ -0,0 +1,13 @@
// all url safe
// can't use uppercase because hostnames are lowercased
var chars = 'abcdefghijklmnopqrstuvwxyz';
module.exports = function rand_id() {
var randomstring = '';
for (var i=0; i<10; ++i) {
var rnum = Math.floor(Math.random() * chars.length);
randomstring += chars[rnum];
}
return randomstring;
}

View File

@@ -6,29 +6,29 @@
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git://github.com/localtunnel/server.git" "url": "git://github.com/shtylman/localtunnel-server.git"
}, },
"dependencies": { "dependencies": {
"book": "1.3.3", "book": "1.3.1",
"debug": "3.1.0", "book-git": "0.0.2",
"esm": "3.0.34", "book-raven": "1.1.0",
"human-readable-ids": "1.0.3", "bookrc": "0.0.1",
"koa": "2.5.1", "debug": "2.2.0",
"koa-router": "7.4.0", "express": "4.13.3",
"http-proxy": "1.12.0",
"localenv": "0.2.2", "localenv": "0.2.2",
"on-finished": "2.3.0",
"optimist": "0.6.1", "optimist": "0.6.1",
"pump": "3.0.0", "stackup": "1.0.1",
"tldjs": "2.3.1" "tldjs": "1.6.1"
}, },
"devDependencies": { "devDependencies": {
"mocha": "5.1.1", "mocha": "2.0.1",
"node-dev": "3.1.3", "localtunnel": "1.8.0",
"supertest": "3.1.0", "ws": "0.8.0"
"ws": "5.1.1"
}, },
"scripts": { "scripts": {
"test": "mocha --check-leaks --require esm './**/*.test.js'", "test": "mocha --ui qunit --reporter spec",
"start": "./bin/server", "start": "./bin/server"
"dev": "node-dev bin/server --port 3000"
} }
} }

179
proxy.js Normal file
View File

@@ -0,0 +1,179 @@
var net = require('net');
var EventEmitter = require('events').EventEmitter;
var log = require('bookrc');
var debug = require('debug')('localtunnel-server');
var Proxy = function(opt, cb) {
if (!(this instanceof Proxy)) {
return new Proxy(opt, cb);
}
var self = this;
self.sockets = [];
self.waiting = [];
var id = opt.id;
// default max is 10
var max_tcp_sockets = opt.max_tcp_sockets || 10;
// new tcp server to service requests for this client
var client_server = net.createServer();
client_server.on('error', function(err) {
if (err.code == 'ECONNRESET' || err.code == 'ETIMEDOUT') {
return;
}
log.error(err);
});
// track initial user connection setup
var conn_timeout;
// user has 5 seconds to connect before their slot is given up
function maybe_tcp_close() {
clearTimeout(conn_timeout);
conn_timeout = setTimeout(function() {
// sometimes the server is already closed but the event has not fired?
try {
clearTimeout(conn_timeout);
client_server.close();
} catch (err) {
cleanup();
}
}, 5000);
}
maybe_tcp_close();
function cleanup() {
debug('closed tcp socket for client(%s)', id);
clearTimeout(conn_timeout);
// clear waiting by ending responses, (requests?)
self.waiting.forEach(function(waiting) {
waiting(null);
});
self.emit('end');
}
// no longer accepting connections for this id
client_server.on('close', cleanup);
// new tcp connection from lt client
client_server.on('connection', function(socket) {
// no more socket connections allowed
if (self.sockets.length >= max_tcp_sockets) {
return socket.end();
}
debug('new connection on port: %s', id);
// a single connection is enough to keep client id slot open
clearTimeout(conn_timeout);
socket.once('close', function(had_error) {
debug('client %s closed socket (error: %s)', id, had_error);
// what if socket was servicing a request at this time?
// then it will be put back in available after right?
// remove this socket
var idx = self.sockets.indexOf(socket);
if (idx >= 0) {
self.sockets.splice(idx, 1);
}
// need to track total sockets, not just active available
debug('remaining client sockets: %s', self.sockets.length);
// no more sockets for this ident
if (self.sockets.length === 0) {
debug('all client(%s) sockets disconnected', id);
maybe_tcp_close();
}
});
// close will be emitted after this
socket.on('error', function(err) {
// we don't log here to avoid logging crap for misbehaving clients
socket.destroy();
});
self.sockets.push(socket);
var wait_cb = self.waiting.shift();
if (wait_cb) {
debug('handling queued request');
self.next_socket(wait_cb);
}
});
client_server.listen(function() {
var port = client_server.address().port;
debug('tcp server listening on port: %d', port);
cb(null, {
// port for lt client tcp connections
port: port,
// maximum number of tcp connections allowed by lt client
max_conn_count: max_tcp_sockets
});
});
};
Proxy.prototype.__proto__ = EventEmitter.prototype;
Proxy.prototype.next_socket = function(cb) {
var self = this;
// socket is a tcp connection back to the user hosting the site
var sock = self.sockets.shift();
// TODO how to handle queue?
// queue request
if (!sock) {
debug('no more client, queue callback');
return self.waiting.push(cb);
}
var done_called = false;
// put the socket back
function done() {
if (done_called) {
throw new Error('done called multiple times');
}
done_called = true;
if (!sock.destroyed) {
debug('retuning socket');
self.sockets.push(sock);
}
// no sockets left to process waiting requests
if (self.sockets.length === 0) {
return;
}
var wait = self.waiting.shift();
debug('processing queued cb');
if (wait) {
return self.next_socket(cb);
}
};
debug('processing request');
cb(sock, done);
};
Proxy.prototype._done = function() {
var self = this;
};
module.exports = Proxy;

368
server.js
View File

@@ -1,167 +1,287 @@
import log from 'book'; var log = require('bookrc');
import Koa from 'koa'; var express = require('express');
import tldjs from 'tldjs'; var tldjs = require('tldjs');
import Debug from 'debug'; var on_finished = require('on-finished');
import http from 'http'; var debug = require('debug')('localtunnel-server');
import { hri } from 'human-readable-ids'; var http_proxy = require('http-proxy');
import Router from 'koa-router'; var http = require('http');
import ClientManager from './lib/ClientManager'; var BindingAgent = require('./lib/BindingAgent');
const debug = Debug('localtunnel:server'); var proxy = http_proxy.createProxyServer({
target: 'http://localtunnel.github.io'
});
export default function(opt) { proxy.on('error', function(err) {
opt = opt || {}; log.error(err);
});
const validHosts = (opt.domain) ? [opt.domain] : undefined; proxy.on('proxyReq', function(proxyReq, req, res, options) {
const myTldjs = tldjs.fromUserSettings({ validHosts }); // rewrite the request so it hits the correct url on github
const landingPage = opt.landing || 'https://localtunnel.github.io/www/'; // also make sure host header is what we expect
proxyReq.path = '/www' + proxyReq.path;
proxyReq.setHeader('host', 'localtunnel.github.io');
});
function GetClientIdFromHostname(hostname) { var Proxy = require('./proxy');
return myTldjs.getSubdomain(hostname); var rand_id = require('./lib/rand_id');
var PRODUCTION = process.env.NODE_ENV === 'production';
// id -> client http server
var clients = Object.create(null);
// proxy statistics
var stats = {
tunnels: 0
};
function maybe_bounce(req, res, sock, head) {
// without a hostname, we won't know who the request is for
var hostname = req.headers.host;
if (!hostname) {
return false;
} }
const manager = new ClientManager(opt); var subdomain = tldjs.getSubdomain(hostname);
if (!subdomain) {
return false;
}
const schema = opt.secure ? 'https' : 'http'; var client_id = subdomain;
var client = clients[client_id];
const app = new Koa(); // no such subdomain
const router = new Router(); // we use 502 error to the client to signify we can't service the request
if (!client) {
res.statusCode = 502;
res.end('localtunnel error: no active client for \'' + client_id + '\'');
req.connection.destroy();
return true;
}
router.get('/api/status', async (ctx, next) => { var finished = false;
const stats = manager.stats; if (sock) {
ctx.body = { sock.once('error', function(err) {
tunnels: stats.tunnels, console.log('sock error', err);
mem: process.memoryUsage(), finished = true;
}; });
});
router.get('/api/tunnels/:id/status', async (ctx, next) => { sock.once('close', function() {
const clientId = ctx.params.id; console.log('finished');
const client = manager.getClient(clientId); finished = true;
if (!client) { });
ctx.throw(404); }
if (res) {
// flag if we already finished before we get a socket
// we can't respond to these requests
on_finished(res, function(err) {
finished = true;
req.connection.destroy();
});
}
// TODO add a timeout, if we run out of sockets, then just 502
// get client port
client.next_socket(function(socket, done) {
done = done || function() {};
// the request already finished or client disconnected
if (finished) {
return done();
}
// happens when client upstream is disconnected
// we gracefully inform the user and kill their conn
// without this, the browser will leave some connections open
// and try to use them again for new requests
// we cannot have this as we need bouncy to assign the requests again
else if (!socket) {
res.statusCode = 504;
res.end();
req.connection.destroy();
return; return;
} }
const stats = client.stats(); socket.once('error', function(err) {
ctx.body = { console.log('socket error', err);
connected_sockets: stats.connectedSockets, });
};
});
app.use(router.routes()); // websocket requests are special in that we simply re-create the header info
app.use(router.allowedMethods()); // and directly pipe the socket data
// avoids having to rebuild the request and handle upgrades via the http client
if (res === null) {
console.log('websocket');
var arr = [req.method + ' ' + req.url + ' HTTP/' + req.httpVersion];
for (var i=0 ; i < (req.rawHeaders.length-1) ; i+=2) {
arr.push(req.rawHeaders[i] + ': ' + req.rawHeaders[i+1]);
}
// root endpoint arr.push('');
app.use(async (ctx, next) => { arr.push('');
const path = ctx.request.path;
socket.pipe(sock).pipe(socket);
socket.write(arr.join('\r\n'));
socket.once('close', function() {
console.log('release websocket');
done();
});
// skip anything not on the root path
if (path !== '/') {
await next();
return; return;
} }
const isNewClientRequest = ctx.query['new'] !== undefined; var agent = new BindingAgent({
if (isNewClientRequest) { socket: socket
const reqId = hri.random(); });
debug('making new client with id %s', reqId);
const info = await manager.newClient(reqId);
const url = schema + '://' + info.id + '.' + ctx.request.host; var opt = {
path: req.url,
agent: agent,
method: req.method,
headers: req.headers
};
console.log('req', req.url);
var client_req = http.request(opt, function(client_res) {
res.writeHead(client_res.statusCode, client_res.statusMessage, client_res.headers);
client_res.pipe(res);
on_finished(client_res, function(err) {
console.log('release client_req', req.url);
done();
});
});
req.pipe(client_req);
client_req.on('error', function(err) { // ??
console.log('client request erorr', err);
done();
});
});
return true;
}
function new_client(id, opt, cb) {
// can't ask for id already is use
// TODO check this new id again
if (clients[id]) {
id = rand_id();
}
var popt = {
id: id,
max_tcp_sockets: opt.max_tcp_sockets
};
var client = Proxy(popt, function(err, info) {
if (err) {
return cb(err);
}
++stats.tunnels;
clients[id] = client;
info.id = id;
cb(err, info);
});
client.on('end', function() {
--stats.tunnels;
delete clients[id];
});
}
module.exports = function(opt) {
opt = opt || {};
var schema = opt.secure ? 'https' : 'http';
var app = express();
app.get('/', function(req, res, next) {
if (req.query['new'] === undefined) {
return next();
}
var req_id = rand_id();
debug('making new client with id %s', req_id);
new_client(req_id, opt, function(err, info) {
if (err) {
res.statusCode = 500;
return res.end(err.message);
}
var url = schema + '://' + req_id + '.' + req.headers.host;
info.url = url; info.url = url;
ctx.body = info; res.json(info);
return; });
}
// no new client request, send to landing page
ctx.redirect(landingPage);
}); });
// anything after the / path is a request for a specific client name app.get('/', function(req, res, next) {
// This is a backwards compat feature proxy.web(req, res);
app.use(async (ctx, next) => { });
const parts = ctx.request.path.split('/');
// any request with several layers of paths is not allowed app.get('/assets/*', function(req, res, next) {
// rejects /foo/bar proxy.web(req, res);
// allow /foo });
if (parts.length !== 2) {
await next();
return;
}
const reqId = parts[1]; app.get('/favicon.ico', function(req, res, next) {
proxy.web(req, res);
});
app.get('/:req_id', function(req, res, next) {
var req_id = req.params.req_id;
// limit requested hostnames to 63 characters // limit requested hostnames to 63 characters
if (! /^(?:[a-z0-9][a-z0-9\-]{4,63}[a-z0-9]|[a-z0-9]{4,63})$/.test(reqId)) { if (! /^[a-z0-9]{4,63}$/.test(req_id)) {
const msg = 'Invalid subdomain. Subdomains must be lowercase and between 4 and 63 alphanumeric characters.'; var err = new Error('Invalid subdomain. Subdomains must be lowercase and between 4 and 63 alphanumeric characters.');
ctx.status = 403; err.statusCode = 403;
ctx.body = { return next(err);
message: msg,
};
return;
} }
debug('making new client with id %s', reqId); debug('making new client with id %s', req_id);
const info = await manager.newClient(reqId); new_client(req_id, opt, function(err, info) {
if (err) {
return next(err);
}
var url = schema + '://' + req_id + '.' + req.headers.host;
info.url = url;
res.json(info);
});
const url = schema + '://' + info.id + '.' + ctx.request.host;
info.url = url;
ctx.body = info;
return;
}); });
const server = http.createServer(); app.use(function(err, req, res, next) {
var status = err.statusCode || err.status || 500;
const appCallback = app.callback(); res.status(status).json({
message: err.message
server.on('request', (req, res) => { });
// without a hostname, we won't know who the request is for
const hostname = req.headers.host;
if (!hostname) {
res.statusCode = 400;
res.end('Host header is required');
return;
}
const clientId = GetClientIdFromHostname(hostname);
if (!clientId) {
appCallback(req, res);
return;
}
const client = manager.getClient(clientId);
if (!client) {
res.statusCode = 404;
res.end('404');
return;
}
client.handleRequest(req, res);
}); });
server.on('upgrade', (req, socket, head) => { var server = http.createServer();
const hostname = req.headers.host;
if (!hostname) {
socket.destroy();
return;
}
const clientId = GetClientIdFromHostname(hostname); server.on('request', function(req, res) {
if (!clientId) { debug('request %s', req.url);
socket.destroy(); if (maybe_bounce(req, res, null, null)) {
return; return;
} };
const client = manager.getClient(clientId); app(req, res);
if (!client) { });
socket.destroy();
server.on('upgrade', function(req, socket, head) {
if (maybe_bounce(req, null, socket, head)) {
return; return;
} };
client.handleUpgrade(req, socket); socket.destroy();
}); });
return server; return server;

View File

@@ -1,109 +0,0 @@
import request from 'supertest';
import assert from 'assert';
import { Server as WebSocketServer } from 'ws';
import WebSocket from 'ws';
import net from 'net';
import createServer from './server';
describe('Server', () => {
it('server starts and stops', async () => {
const server = createServer();
await new Promise(resolve => server.listen(resolve));
await new Promise(resolve => server.close(resolve));
});
it('should redirect root requests to landing page', async () => {
const server = createServer();
const res = await request(server).get('/');
assert.equal('https://localtunnel.github.io/www/', res.headers.location);
});
it('should support custom base domains', async () => {
const server = createServer({
domain: 'domain.example.com',
});
const res = await request(server).get('/');
assert.equal('https://localtunnel.github.io/www/', res.headers.location);
});
it('reject long domain name requests', async () => {
const server = createServer();
const res = await request(server).get('/thisdomainisoutsidethesizeofwhatweallowwhichissixtythreecharacters');
assert.equal(res.body.message, 'Invalid subdomain. Subdomains must be lowercase and between 4 and 63 alphanumeric characters.');
});
it('should upgrade websocket requests', async () => {
const hostname = 'websocket-test';
const server = createServer({
domain: 'example.com',
});
await new Promise(resolve => server.listen(resolve));
const res = await request(server).get('/websocket-test');
const localTunnelPort = res.body.port;
const wss = await new Promise((resolve) => {
const wsServer = new WebSocketServer({ port: 0 }, () => {
resolve(wsServer);
});
});
const websocketServerPort = wss.address().port;
const ltSocket = net.createConnection({ port: localTunnelPort });
const wsSocket = net.createConnection({ port: websocketServerPort });
ltSocket.pipe(wsSocket).pipe(ltSocket);
wss.once('connection', (ws) => {
ws.once('message', (message) => {
ws.send(message);
});
});
const ws = new WebSocket('http://localhost:' + server.address().port, {
headers: {
host: hostname + '.example.com',
}
});
ws.on('open', () => {
ws.send('something');
});
await new Promise((resolve) => {
ws.once('message', (msg) => {
assert.equal(msg, 'something');
resolve();
});
});
wss.close();
await new Promise(resolve => server.close(resolve));
});
it('should support the /api/tunnels/:id/status endpoint', async () => {
const server = createServer();
await new Promise(resolve => server.listen(resolve));
// no such tunnel yet
const res = await request(server).get('/api/tunnels/foobar-test/status');
assert.equal(res.statusCode, 404);
// request a new client called foobar-test
{
const res = await request(server).get('/foobar-test');
}
{
const res = await request(server).get('/api/tunnels/foobar-test/status');
assert.equal(res.statusCode, 200);
assert.deepEqual(res.body, {
connected_sockets: 0,
});
}
await new Promise(resolve => server.close(resolve));
});
});

149
test/basic.js Normal file
View File

@@ -0,0 +1,149 @@
var http = require('http');
var url = require('url');
var assert = require('assert');
var localtunnel = require('localtunnel');
var localtunnel_server = require('../server')();
suite('basic');
var lt_server_port
before('set up localtunnel server', function(done) {
var server = localtunnel_server.listen(function() {
lt_server_port = server.address().port;
done();
});
});
test('landing page', function(done) {
var opt = {
host: 'localhost',
port: lt_server_port,
headers: {
host: 'example.com'
},
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(body.indexOf('<title>Localtunnel ~ Expose yourself to the world</title>') > 0);
done();
});
});
req.end();
});
before('set up 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;
done();
});
});
before('set up localtunnel client', function(done) {
var opt = {
host: 'http://localhost:' + lt_server_port,
};
localtunnel(test._fake_port, opt, function(err, tunnel) {
assert.ifError(err);
var url = tunnel.url;
assert.ok(new RegExp('^http:\/\/.*localhost:' + lt_server_port + '$').test(url));
test._fake_url = url;
done(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: lt_server_port,
headers: {
host: hostname + '.tld'
},
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 opt = {
host: 'http://localhost:' + lt_server_port,
subdomain: 'abcd'
};
localtunnel(test._fake_port, opt, function(err, tunnel) {
assert.ifError(err);
var url = tunnel.url;
assert.ok(new RegExp('^http:\/\/.*localhost:' + lt_server_port + '$').test(url));
test._fake_url = url;
done(err);
});
});
test('request domain that is too long', function(done) {
var opt = {
host: 'http://localhost:' + lt_server_port,
subdomain: 'thisdomainisoutsidethesizeofwhatweallowwhichissixtythreecharacters'
};
localtunnel(test._fake_port, opt, function(err, tunnel) {
assert(err);
assert.equal(err.message, 'Invalid subdomain. Subdomains must be lowercase and between 4 and 63 alphanumeric characters.');
done();
});
});
test('request uppercase domain', function(done) {
var opt = {
host: 'http://localhost:' + lt_server_port,
subdomain: 'ABCD'
};
localtunnel(test._fake_port, opt, function(err, tunnel) {
assert(err);
assert.equal(err.message, 'Invalid subdomain. Subdomains must be lowercase and between 4 and 63 alphanumeric characters.');
done();
});
});
after('shutdown', function() {
localtunnel_server.close();
});

103
test/queue.js Normal file
View File

@@ -0,0 +1,103 @@
var http = require('http');
var url = require('url');
var assert = require('assert');
var localtunnel = require('localtunnel');
suite('queue');
var localtunnel_server = require('../server')({
max_tcp_sockets: 1
});
var server;
var lt_server_port;
before('set up localtunnel server', function(done) {
var lt_server = localtunnel_server.listen(function() {
lt_server_port = lt_server.address().port;
done();
});
});
before('set up 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');
}, 500);
});
server.listen(function() {
var port = server.address().port;
test._fake_port = port;
done();
});
});
before('set up localtunnel client', function(done) {
var opt = {
host: 'http://localhost:' + lt_server_port,
};
localtunnel(test._fake_port, opt, function(err, tunnel) {
assert.ifError(err);
var url = tunnel.url;
assert.ok(new RegExp('^http:\/\/.*localhost:' + lt_server_port + '$').test(url));
test._fake_url = url;
done(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: lt_server_port,
agent: false,
headers: {
host: hostname + '.tld'
},
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();
}
});
after('shutdown', function() {
localtunnel_server.close();
});

68
test/simple.js Normal file
View File

@@ -0,0 +1,68 @@
var http = require('http');
var url = require('url');
var assert = require('assert');
var localtunnel = require('localtunnel');
var localtunnel_server = require('../server')({
max_tcp_sockets: 2
});
var lt_server_port
suite('simple');
test('set up localtunnel server', function(done) {
var server = localtunnel_server.listen(function() {
lt_server_port = server.address().port;
done();
});
});
test('set up local http server', function(done) {
var server = http.createServer(function(req, res) {
res.end('hello world!');
});
server.listen(function() {
test._fake_port = server.address().port;
done();
});
});
test('set up localtunnel client', function(done) {
var opt = {
host: 'http://localhost:' + lt_server_port,
};
localtunnel(test._fake_port, opt, function(err, tunnel) {
assert.ifError(err);
var url = tunnel.url;
assert.ok(new RegExp('^http:\/\/.*localhost:' + lt_server_port + '$').test(url));
test._fake_url = url;
done(err);
});
});
test('should respond to request', function(done) {
var hostname = url.parse(test._fake_url).hostname;
var opt = {
host: 'localhost',
port: lt_server_port,
headers: {
host: hostname + '.tld'
}
};
http.get(opt, function(res) {
var body = '';
res.setEncoding('utf-8');
res.on('data', function(chunk) {
body += chunk;
});
res.on('end', function() {
assert.equal(body, 'hello world!');
done();
});
});
});

74
test/websocket.js Normal file
View File

@@ -0,0 +1,74 @@
var http = require('http');
var url = require('url');
var assert = require('assert');
var localtunnel = require('localtunnel');
var WebSocket = require('ws');
var WebSocketServer = require('ws').Server;
var localtunnel_server = require('../server')({
max_tcp_sockets: 2
});
var lt_server_port
suite('websocket');
before('set up localtunnel server', function(done) {
var server = localtunnel_server.listen(function() {
lt_server_port = server.address().port;
done();
});
});
before('set up local websocket server', function(done) {
var wss = new WebSocketServer({ port: 0 }, function() {
test._fake_port = wss._server.address().port;
done();
});
wss.on('error', function(err) {
done(err);
});
wss.on('connection', function connection(ws) {
ws.on('error', function(err) {
done(err);
});
ws.on('message', function incoming(message) {
ws.send(message);
});
});
});
before('set up localtunnel client', function(done) {
var opt = {
host: 'http://localhost:' + lt_server_port,
};
localtunnel(test._fake_port, opt, function(err, tunnel) {
assert.ifError(err);
var url = tunnel.url;
assert.ok(new RegExp('^http:\/\/.*localhost:' + lt_server_port + '$').test(url));
test._fake_url = url;
done(err);
});
});
test('websocket server request', function(done) {
var hostname = url.parse(test._fake_url).hostname;
var ws = new WebSocket('http://localhost:' + lt_server_port, {
headers: {
host: hostname + '.tld'
}
});
ws.on('message', function(msg) {
assert.equal(msg, 'something');
done();
});
ws.on('open', function open() {
ws.send('something');
});
});

1066
yarn.lock

File diff suppressed because it is too large Load Diff