From 18041ae3fb4febf0a9bd3798df46c19da95ac7b6 Mon Sep 17 00:00:00 2001 From: tedspare Date: Tue, 19 Mar 2024 22:05:08 -0400 Subject: [PATCH] Migrate to Bun websockets --- README.md | 70 ------------ client.ts | 41 +++++++ client/demo.ts => demo.ts | 0 lib/Client.test.js | 157 --------------------------- lib/ClientManager.test.js | 90 ---------------- lib/TunnelAgent.test.js | 183 ------------------------------- lib/agent.ts | 199 ---------------------------------- lib/client.ts | 142 ------------------------ lib/manager.ts | 102 ------------------ opus.ts | 110 ------------------- package.json | 17 +-- server.test.js | 109 ------------------- server.ts | 222 +++++++++++--------------------------- 13 files changed, 106 insertions(+), 1336 deletions(-) delete mode 100644 README.md create mode 100644 client.ts rename client/demo.ts => demo.ts (100%) delete mode 100644 lib/Client.test.js delete mode 100644 lib/ClientManager.test.js delete mode 100644 lib/TunnelAgent.test.js delete mode 100644 lib/agent.ts delete mode 100644 lib/client.ts delete mode 100644 lib/manager.ts delete mode 100644 opus.ts delete mode 100644 server.test.js diff --git a/README.md b/README.md deleted file mode 100644 index 9c829f0..0000000 --- a/README.md +++ /dev/null @@ -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 -``` diff --git a/client.ts b/client.ts new file mode 100644 index 0000000..28e57aa --- /dev/null +++ b/client.ts @@ -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}` }); diff --git a/client/demo.ts b/demo.ts similarity index 100% rename from client/demo.ts rename to demo.ts diff --git a/lib/Client.test.js b/lib/Client.test.js deleted file mode 100644 index 5a0afd0..0000000 --- a/lib/Client.test.js +++ /dev/null @@ -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(); - }); -}); diff --git a/lib/ClientManager.test.js b/lib/ClientManager.test.js deleted file mode 100644 index d85abe5..0000000 --- a/lib/ClientManager.test.js +++ /dev/null @@ -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); -}); diff --git a/lib/TunnelAgent.test.js b/lib/TunnelAgent.test.js deleted file mode 100644 index 9505f35..0000000 --- a/lib/TunnelAgent.test.js +++ /dev/null @@ -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, - }); - }); -}); diff --git a/lib/agent.ts b/lib/agent.ts deleted file mode 100644 index 2b57063..0000000 --- a/lib/agent.ts +++ /dev/null @@ -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(); - } -} diff --git a/lib/client.ts b/lib/client.ts deleted file mode 100644 index cb8d9b0..0000000 --- a/lib/client.ts +++ /dev/null @@ -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")); - }); - } -} diff --git a/lib/manager.ts b/lib/manager.ts deleted file mode 100644 index 87b8f92..0000000 --- a/lib/manager.ts +++ /dev/null @@ -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; - 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(); - } -} diff --git a/opus.ts b/opus.ts deleted file mode 100644 index 63dbd45..0000000 --- a/opus.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { serve, type Socket } from "bun"; -import { uid, parseResponse, wait } from "./utils"; - -const clientSockets = new Map(); -const clientData = new Map(); - -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}`); diff --git a/package.json b/package.json index 58996ad..ab92f21 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/server.test.js b/server.test.js deleted file mode 100644 index 10bf8bc..0000000 --- a/server.test.js +++ /dev/null @@ -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)); - }); -}); \ No newline at end of file diff --git a/server.ts b/server.ts index 093776b..c07a17f 100644 --- a/server.ts +++ b/server.ts @@ -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>(); +const clientData = new Map(); -export const CreateServer = (opt: Options) => { - opt = opt || {}; +serve({ + 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}`);