sub #215

Closed
DexterStorey wants to merge 12 commits from sub into master
13 changed files with 106 additions and 1336 deletions
Showing only changes of commit 18041ae3fb - Show all commits
-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
```
+41
View File
@@ -0,0 +1,41 @@
import { parseArgs } from "util";
const serverUrl = `ws://localhost:1234?new`;
async function main({ url }: { url: string }) {
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(socket.send);
}
});
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",
},
},
allowPositionals: true,
});
if (!values.port) throw "pass --port 3000";
main({ url: `localhost:${values.port}` });
View File
-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();
});
});
-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);
});
-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,
});
});
});
-199
View File
@@ -1,199 +0,0 @@
import { Agent } from "http";
import net from "net";
import Debug from "debug";
import { type AddressInfo } from "ws";
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
export class TunnelAgent extends Agent {
public availableSockets: any[];
public waitingCreateConn: any[];
public debug: any;
public connectedSockets: number;
public maxTcpSockets: number;
public server: net.Server;
public started: boolean;
public closed: boolean;
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((e) => {
console.log(`\n\n\n\x1b[33m==>NET \x1b[0m\n`);
console.log(e);
});
// flag to avoid double starts
this.started = false;
this.closed = false;
}
stats() {
return { connectedSockets: this.connectedSockets };
}
listen(): Promise<{ port: number }> {
const server = this.server;
if (this.started) throw new Error("already started");
this.started = true;
server.on("close", (e) => {
this._onClose.bind(this);
});
server.on("connection", (e) => {
console.log(`\n\n\n\x1b[33m==>CXN \x1b[0m\n`);
console.log(e);
return this._onConnection.bind(this);
});
server.on("error", (err: any) => {
console.log(`\n\n\n\x1b[33m==>HERE1 \x1b[0m\n`);
// These errors happen from killed connections, we don't worry about them
if (err.code == "ECONNRESET" || err.code == "ETIMEDOUT") {
return;
}
console.error(err);
});
return new Promise((resolve) => {
server.listen(() => {
const port = (server.address() as AddressInfo).port;
this.debug("tcp server listening on port: %d", port);
// port for lt client tcp connections
resolve({
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) {
console.log(`\n\n\n\x1b[33m==>HERE2 \x1b[0m\n`);
// 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) => {
console.log(`\n\n\n\x1b[33m==>HERE3 \x1b[0m\n`);
// 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) {
console.log(`\n\n\n\x1b[33m==>HERE4 \x1b[0m\n`);
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();
}
}
-142
View File
@@ -1,142 +0,0 @@
import http from "http";
import Debug from "debug";
import pump from "pump";
import EventEmitter from "events";
import { TunnelAgent } from "./agent";
// 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
export default class Client extends EventEmitter {
public agent: TunnelAgent;
public id: string;
public debug: any;
public graceTimeout: any;
constructor(options: { id: string; agent: TunnelAgent }) {
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(() => {
console.log(`Timed out. Closing client.`);
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(() => {
console.log(`Timed out #2. Closing client.`);
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) => {
console.error(err);
this.close();
});
}
stats() {
return this.agent.stats();
}
close() {
clearTimeout(this.graceTimeout);
this.agent.destroy();
this.emit("close");
}
handleRequest(req, res) {
console.log(`\n\n\n\x1b[33m==>HANDLE REQ \x1b[0m\n`);
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) => {
console.log(`\n\n\n\x1b[33m==>CLIENT REQ ERR \x1b[0m\n`);
console.log(err);
// TODO: 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) {
console.log(`\n\n\n\x1b[33m==>NOT READABLE OR WRITABLE \x1b[0m\n`);
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"));
});
}
}
-102
View File
@@ -1,102 +0,0 @@
import { hri } from "human-readable-ids";
import Debug from "debug";
import Client from "./client";
import { TunnelAgent } from "./agent";
import { Options } from "../server";
// Manage sets of clients
// A client is a "user session" established to service a remote localtunnel client
export class ClientManager {
public opt: Options;
public clients: Map<string, Client>;
public stats: { tunnels: number };
public debug: any;
constructor(opt: Options) {
this.opt = opt || {};
// id -> client instance
this.clients = new Map();
// statistics
this.stats = {
tunnels: 0,
};
this.debug = Debug("lt:ClientManager");
}
// 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: string) {
const clients = this.clients;
const stats = this.stats;
// can't ask for id already is use
if (clients.has(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 to avoid races with other clients requesting same id
clients.set(id, client);
client.once("close", (e) => {
console.log(`\n\n\n\x1b[33m==>Closing: \x1b[0m\n`);
console.log(e);
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,
url: "",
};
} catch (err) {
this.removeClient(id);
// rethrow error for upstream to handle
throw err;
}
}
getClient(id: string) {
return this.clients.get(id);
}
hasClient(id: string) {
return !!this.getClient(id);
}
removeClient(id: string) {
this.debug("removing client: %s", id);
const client = this.getClient(id);
if (!client) {
console.log(`\n\n\n\x1b[33m==>NO CLIENT \x1b[0m\n`);
return;
}
--this.stats.tunnels;
delete this.clients[id];
client.close();
}
}
-110
View File
@@ -1,110 +0,0 @@
import { serve, type Socket } from "bun";
import { uid, parseResponse, wait } from "./utils";
const clientSockets = new Map<string, Socket>();
const clientData = new Map<string, any>();
const port = Bun.env.PORT || 1234;
const protocol = Bun.env.PROTOCOL || "http";
serve({
port,
fetch: async (req: Request) => {
const url = new URL(req.url);
const hostname = `${url.hostname}${url.port && `:${url.port}`}`;
// For healthchecks
if (url.pathname === "/healthz") return new Response("healthy");
if (url.searchParams.has("new")) {
const clientId = uid();
const server = Bun.listen({
hostname: "localhost",
port: 0, // Let the OS assign an available port
socket: {
data(_socket, data) {
clientData.set(clientId, data);
},
open(socket) {
console.log(`Client ${clientId} connected`);
clientSockets.set(clientId, socket);
},
close() {
console.log(`Client ${clientId} reconnecting`);
clientSockets.delete(clientId);
clientData.delete(clientId);
},
error(_socket, error) {
console.error(`Error for client ${clientId}:`, error);
},
},
});
return new Response(
JSON.stringify({
id: clientId,
port: server.port,
url: `${protocol}://${clientId}.${hostname}`,
}),
{
headers: { "Content-Type": "application/json" },
status: 200,
}
);
}
if (hostname.split(".").length === 3) {
return new Response("welcome to tunnel. hit ?new to get a URL.");
}
const subdomain = hostname.split(".")[0];
const clientSocket = clientSockets.get(subdomain);
if (!clientSocket) {
console.warn(`Client ${subdomain} not found`);
return new Response("Client not found", { status: 404 });
}
console.log(`Forwarding request to client ${subdomain}`);
// Forward the request to the connected client
console.log(req.method, url.pathname, url.search);
clientSocket.write(
`${req.method} ${url.pathname}${url.search} HTTP/1.1\r\n`
);
for (const [header, value] of req.headers.entries()) {
clientSocket.write(`${header}: ${value}\r\n`);
}
clientSocket.write("\r\n");
if (req.body) {
const reader = req.body.getReader();
let chunk;
while (!(chunk = await reader.read()).done) {
clientSocket.write(chunk.value);
}
}
let responseData = clientData.get(subdomain);
let attempts = 10;
while (!responseData) {
await wait(100);
responseData = clientData.get(subdomain);
if (attempts < 1) {
return new Response("Client did not respond", { status: 504 });
}
}
const { body, headers } = parseResponse(responseData);
return new Response(body, { headers, status: 200 });
},
error(error) {
console.error(error);
return new Response(error.message, { status: 500 });
},
});
console.log(`Listening on port ${port}`);
+4 -13
View File
@@ -8,22 +8,13 @@
"url": "https://github.com/rubriclab/tunnel"
},
"dependencies": {
"debug": "3.1.0",
"human-id": "^4.1.1",
"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": {
"@types/bun": "^1.0.8",
"ws": "5.1.1"
"@types/bun": "^1.0.8"
},
"scripts": {
"start": "bun run opus.ts",
"dev": "bun run --watch index.ts --domain ted.rubric.sh --port 3001"
"start": "bun run server.ts",
"lt": "bun run client/index.ts"
}
}
-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));
});
});
+61 -161
View File
@@ -1,178 +1,78 @@
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/manager";
import { ServerWebSocket, serve } from "bun";
import { uid, wait } from "./utils";
export type Options = {
domain: string;
secure: boolean;
landing?: string;
max_tcp_sockets?: number;
};
type Client = { id: string };
const debug = Debug("localtunnel:server");
const port = 1234;
const protocol = "http";
const clients = new Map<string, ServerWebSocket<Client>>();
const clientData = new Map<string, any>();
export const CreateServer = (opt: Options) => {
opt = opt || {};
serve<Client>({
port,
fetch: async (req, server) => {
const reqUrl = new URL(req.url);
const validHosts = opt.domain ? [opt.domain] : undefined;
const myTldjs = tldjs.fromUserSettings({ validHosts });
const landingPage = opt.landing || "https://localtunnel.github.io/www/";
function getClientIdFromHostname(hostname: string) {
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;
if (reqUrl.searchParams.has("new")) {
const upgraded = server.upgrade(req, { data: { id: uid() } });
if (upgraded) return;
else return new Response("Upgrade failed :(", { status: 500 });
}
const stats = client.stats();
ctx.body = {
connected_sockets: stats.connectedSockets,
};
});
console.log("user req:", req.url);
const subdomain = reqUrl.hostname.split(".")[0];
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;
if (!clients.has(subdomain)) {
console.log(`\x1b[31m${subdomain} not found \x1b[0m`);
return new Response("client not found :(", { status: 404 });
}
const isNewClientRequest = ctx.query["new"] !== undefined;
if (isNewClientRequest) {
const reqId = hri.random();
// 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 }));
debug("making new client with id %s", reqId);
// Wait for the client to cache its response above
await wait(1);
const info = await manager.newClient(reqId);
let retries = 5;
let res = clientData.get(subdomain);
const url = schema + "://" + info.id + "." + ctx.request.host;
while (!res) {
await wait(1000);
retries--;
info.url = url;
ctx.body = info;
res = clientData.get(subdomain);
return;
if (retries < 1) {
console.log(`\x1b[31m${subdomain} not responding \x1b[0m`);
return new Response("client not responding :(", { status: 500 });
}
}
// no new client request, send to landing page
ctx.redirect(landingPage);
});
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}.localhost:${port}`,
})
);
},
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);
},
},
});
// 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) => {
console.log(`\n\n\n\x1b[33m==>UPGRADE \x1b[0m\n`);
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;
};
console.log(`Websocket server on port ${port}`);