sub #215

Closed
DexterStorey wants to merge 12 commits from sub into master
22 changed files with 278 additions and 2344 deletions
-1
View File
@@ -1 +0,0 @@
save-exact = true
-4
View File
@@ -1,4 +0,0 @@
language: node_js
sudo: false
node_js:
- "9.2"
+6 -4
View File
@@ -1,13 +1,15 @@
FROM node:10.1.0-alpine
FROM oven/bun:1 as base
WORKDIR /app
COPY package.json /app/
COPY yarn.lock /app/
COPY bun.lockb /app/
RUN yarn install --production && yarn cache clean
RUN bun install --frozen-lockfile --production
COPY . /app
ENV NODE_ENV production
ENTRYPOINT ["node", "-r", "esm", "./bin/server"]
ENTRYPOINT ["bun", "run", "server"]
EXPOSE 1234
-70
View File
@@ -1,70 +0,0 @@
# localtunnel-server
[![Build Status](https://travis-ci.org/localtunnel/server.svg?branch=master)](https://travis-ci.org/localtunnel/server)
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.
This repo is the server component. If you are just looking for the CLI localtunnel app, see (https://github.com/localtunnel/localtunnel).
## overview ##
The default localtunnel client connects to the `localtunnel.me` server. You can, however, easily set up and run your own server. In order to run your own localtunnel server you must ensure that your server can meet the following requirements:
* You can set up DNS entries for your `domain.tld` and `*.domain.tld` (or `sub.domain.tld` and `*.sub.domain.tld`).
* The server can accept incoming TCP connections for any non-root TCP port (i.e. ports over 1000).
The above are important as the client will ask the server for a subdomain under a particular domain. The server will listen on any OS-assigned TCP port for client connections.
#### setup
```shell
# pick a place where the files will live
git clone git://github.com/defunctzombie/localtunnel-server.git
cd localtunnel-server
npm install
# server set to run on port 1234
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).
**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
You can now use your domain with the `--host` flag for the `lt` client.
```shell
lt --host http://sub.example.tld:1234 --port 9000
```
You will be assigned a URL similar to `heavy-puma-9.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.
## 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
```
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env node -r esm
import 'localenv';
import optimist from 'optimist';
import log from 'book';
import Debug from 'debug';
import CreateServer from '../server';
const debug = Debug('localtunnel');
const argv = optimist
.usage('Usage: $0 --port [num]')
.options('secure', {
default: false,
describe: 'use this flag to indicate proxy over https'
})
.options('port', {
default: '80',
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', {
default: 10,
describe: 'maximum number of tcp sockets each client is allowed to establish at one time (the tunnels)'
})
.argv;
if (argv.help) {
optimist.showHelp();
process.exit();
}
const server = CreateServer({
max_tcp_sockets: argv['max-sockets'],
secure: argv.secure,
domain: argv.domain,
});
server.listen(argv.port, argv.address, () => {
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
Executable
BIN
View File
Binary file not shown.
+50
View File
@@ -0,0 +1,50 @@
import { parseArgs } from "util";
async function main({ url, domain, subdomain }: { url: string; domain?: string, subdomain?: string }) {
const serverUrl = `ws://${domain || "localhost:1234"}?new${subdomain ? `&subdomain=${subdomain}` : ""}`;
const socket = new WebSocket(serverUrl);
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
console.log("message:", data);
if (data.method) {
fetch(`${url}${data.path}`, {
method: data.method,
headers: data.headers,
})
.then((res) => res.text())
.then((res) => {
socket.send(res);
});
}
});
socket.addEventListener("open", (event) => {
console.log("socket ready:", !!(event.target as any).readyState);
socket.ping();
});
}
const { values } = parseArgs({
args: Bun.argv,
options: {
port: {
type: "string",
required: true,
short: "p",
},
domain: {
type: "string",
short: "d",
},
subdomain: {
type: "string",
short: "s",
},
},
allowPositionals: true,
});
if (!values.port) throw "pass --port 3000";
main({ url: `localhost:${values.port}`, domain: values.domain, subdomain: values.subdomain });
+22
View File
@@ -0,0 +1,22 @@
import { serve } from "bun";
const port = 3000;
serve({
port,
fetch: async (req) => {
const url = new URL(req.url);
console.log(req.method, url.pathname, url.search);
const bod = await req.text();
bod && console.log("Body: ", bod);
const res = new Response(`hello from localhost:${port}`, {
status: 200,
});
return res;
},
});
console.log(`Serving on port ${port}!`);
+20
View File
@@ -0,0 +1,20 @@
# fly.toml app configuration file generated for tunnel on 2024-03-18T21:37:28-04:00
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
app = 'tunnel'
primary_region = 'yul'
[build]
[http_service]
internal_port = 1234
force_https = false
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
processes = ['app']
[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1
Executable
+67
View File
@@ -0,0 +1,67 @@
import "localenv";
import optimist from "optimist";
import Debug from "debug";
import { CreateServer } from "./server";
import { type AddressInfo } from "ws";
const debug = Debug("localtunnel");
const argv = optimist
.usage("Usage: $0 --port [num]")
.options("secure", {
default: false,
describe: "use this flag to indicate proxy over https",
})
.options("port", {
default: "80",
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", {
default: 10,
describe:
"maximum number of tcp sockets each client is allowed to establish at one time (the tunnels)",
}).argv;
if (argv.help) {
console.log("Showing help");
optimist.showHelp();
process.exit();
}
const server = CreateServer({
max_tcp_sockets: argv["max-sockets"],
secure: argv.secure,
domain: argv.domain,
});
server.listen(argv.port, argv.address, () => {
debug("server listening on port: %d", (server.address() as AddressInfo).port);
});
process.on("SIGINT", () => {
console.error("SIGINT");
process.exit();
});
process.on("SIGTERM", () => {
console.warn("SIGTERM");
// process.exit();
});
process.on("uncaughtException", (err) => {
console.error("err:");
console.error(err);
});
process.on("unhandledRejection", (reason, promise) => {
console.error("reason:");
console.error(reason);
});
-132
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;
-157
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();
});
});
-96
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;
-90
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);
});
-175
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;
-183
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,
});
});
});
+8 -21
View File
@@ -1,34 +1,21 @@
{
"author": "Roman Shtylman <[email protected]>",
"name": "localtunnel-server",
"name": "bunl",
"description": "expose localhost to the world",
"version": "0.0.8",
"version": "0.0.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "git://github.com/localtunnel/server.git"
"url": "https://github.com/rubriclab/bunl"
},
"dependencies": {
"book": "1.3.3",
"debug": "3.1.0",
"esm": "3.0.34",
"human-readable-ids": "1.0.3",
"koa": "2.5.1",
"koa-router": "7.4.0",
"localenv": "0.2.2",
"optimist": "0.6.1",
"pump": "3.0.0",
"tldjs": "2.3.1"
"human-id": "^4.1.1"
},
"devDependencies": {
"mocha": "5.1.1",
"node-dev": "3.1.3",
"supertest": "3.1.0",
"ws": "5.1.1"
"@types/bun": "^1.0.8"
},
"scripts": {
"test": "mocha --check-leaks --require esm './**/*.test.js'",
"start": "./bin/server",
"dev": "node-dev bin/server --port 3000"
"server": "bun run server.ts",
"lt": "bun run client.ts",
"demo": "bun run --hot demo.ts"
}
}
-168
View File
@@ -1,168 +0,0 @@
import log from 'book';
import Koa from 'koa';
import tldjs from 'tldjs';
import Debug from 'debug';
import http from 'http';
import { hri } from 'human-readable-ids';
import Router from 'koa-router';
import ClientManager from './lib/ClientManager';
const debug = Debug('localtunnel:server');
export default function(opt) {
opt = opt || {};
const validHosts = (opt.domain) ? [opt.domain] : undefined;
const myTldjs = tldjs.fromUserSettings({ validHosts });
const landingPage = opt.landing || 'https://localtunnel.github.io/www/';
function GetClientIdFromHostname(hostname) {
return myTldjs.getSubdomain(hostname);
}
const manager = new ClientManager(opt);
const schema = opt.secure ? 'https' : 'http';
const app = new Koa();
const router = new Router();
router.get('/api/status', async (ctx, next) => {
const stats = manager.stats;
ctx.body = {
tunnels: stats.tunnels,
mem: process.memoryUsage(),
};
});
router.get('/api/tunnels/:id/status', async (ctx, next) => {
const clientId = ctx.params.id;
const client = manager.getClient(clientId);
if (!client) {
ctx.throw(404);
return;
}
const stats = client.stats();
ctx.body = {
connected_sockets: stats.connectedSockets,
};
});
app.use(router.routes());
app.use(router.allowedMethods());
// root endpoint
app.use(async (ctx, next) => {
const path = ctx.request.path;
// skip anything not on the root path
if (path !== '/') {
await next();
return;
}
const isNewClientRequest = ctx.query['new'] !== undefined;
if (isNewClientRequest) {
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;
info.url = url;
ctx.body = 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
// This is a backwards compat feature
app.use(async (ctx, next) => {
const parts = ctx.request.path.split('/');
// any request with several layers of paths is not allowed
// rejects /foo/bar
// allow /foo
if (parts.length !== 2) {
await next();
return;
}
const reqId = parts[1];
// 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)) {
const msg = 'Invalid subdomain. Subdomains must be lowercase and between 4 and 63 alphanumeric characters.';
ctx.status = 403;
ctx.body = {
message: msg,
};
return;
}
debug('making new client with id %s', reqId);
const info = await manager.newClient(reqId);
const url = schema + '://' + info.id + '.' + ctx.request.host;
info.url = url;
ctx.body = info;
return;
});
const server = http.createServer();
const appCallback = app.callback();
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) => {
const hostname = req.headers.host;
if (!hostname) {
socket.destroy();
return;
}
const clientId = GetClientIdFromHostname(hostname);
if (!clientId) {
socket.destroy();
return;
}
const client = manager.getClient(clientId);
if (!client) {
socket.destroy();
return;
}
client.handleUpgrade(req, socket);
});
return server;
};
-109
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));
});
});
+81
View File
@@ -0,0 +1,81 @@
import { ServerWebSocket, serve } from "bun";
import { uid, wait } from "./utils";
type Client = { id: string };
const port = 1234;
const protocol = "http";
const domain = Bun.env.DOMAIN || `localhost:${port}`;
console.log("domain:", domain);
const clients = new Map<string, ServerWebSocket<Client>>();
const clientData = new Map<string, any>();
serve<Client>({
port,
fetch: async (req, server) => {
const reqUrl = new URL(req.url);
if (reqUrl.searchParams.has("new")) {
const id = reqUrl.searchParams.get("subdomain") || uid();
const upgraded = server.upgrade(req, { data: { id } });
if (upgraded) return;
else return new Response("Upgrade failed :(", { status: 500 });
}
console.log("user req:", req.url);
const subdomain = reqUrl.hostname.split(".")[0];
if (!clients.has(subdomain)) {
console.log(`\x1b[31m${subdomain} not found \x1b[0m`);
return new Response("client not found :(", { status: 404 });
}
// The magic: forward the req to the client
const client = clients.get(subdomain)!;
const { method, url, headers } = req;
const path = new URL(url).pathname;
client.send(JSON.stringify({ method, path, headers }));
// Wait for the client to cache its response above
await wait(1);
let retries = 5;
let res = clientData.get(subdomain);
while (!res) {
await wait(1000);
retries--;
res = clientData.get(subdomain);
if (retries < 1) {
console.log(`\x1b[31m${subdomain} not responding \x1b[0m`);
return new Response("client not responding :(", { status: 500 });
}
}
return new Response(res, { status: 200 });
},
websocket: {
open(ws) {
console.log("connecting to", ws.data.id);
clients.set(ws.data.id, ws);
ws.send(
JSON.stringify({
id: ws.data.id,
url: `${protocol}://${ws.data.id}.${domain}`,
})
);
},
message(ws, message) {
console.log("message from", ws.data.id, message);
clientData.set(ws.data.id, message);
},
close(ws) {
console.log(`closing ${ws.data.id}`);
clients.delete(ws.data.id);
},
},
});
console.log(`Websocket server on port ${port}`);
+24
View File
@@ -0,0 +1,24 @@
import { humanId } from "human-id";
export const uid = () => {
return humanId({ capitalize: false, separator: "-" });
};
export const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
export const parseResponse = (buffer: Buffer) => {
const asString = Buffer.from(buffer).toString();
const [responseHeaders, body] = asString.split("\r\n\r\n");
const headers = new Headers();
responseHeaders
.trim()
.split("\r\n")
.forEach((header) => {
const [name, value] = header.split(": ");
if (!name.includes("HTTP")) headers.append(name, value);
});
return { body, headers };
};
-1066
View File
File diff suppressed because it is too large Load Diff