diff --git a/README.md b/README.md index a5183c8..4cb5b21 100644 --- a/README.md +++ b/README.md @@ -30,57 +30,79 @@ the server, and both are configured to reject anything else. This way, the pair ## Usage + +``` +usage: h2tunnel [options] + +commands: + client + server + +client options: + --crt Path to certificate file (.crt) + --key Path to private key file (.key) + --tunnel-host Host for the tunnel server + --tunnel-port Port for the tunnel server + --origin-host Host for the local TCP server (default: localhost) + --origin-port Port for the local TCP server + +server options: + --crt Path to certificate file (.crt) + --key Path to private key file (.key) + --tunnel-listen-ip IP for the tunnel server to bind on (default: 0.0.0.0) + --tunnel-listen-port Port for the tunnel server to listen on + --proxy-listen-ip IP for the remote TCP proxy server to bind on (default: 0.0.0.0) + --proxy-listen-port Port for the remote TCP proxy server to listen on + +The tunnel and proxy servers will bind to 0.0.0.0 by default which will make them publically available. This requires +superuser permissions on Linux. You can change this setting to bind to a specific network interface, e.g. a VPN, but +this is advanced usage. +``` + Generate `h2tunnel.key` and `h2tunnel.crt` files using `openssl` command: ```bash openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:secp384r1 -days 3650 -nodes -keyout h2tunnel.key -out h2tunnel.crt -subj "/CN=localhost" ``` -### Forward localhost:8000 to http://example.com +### Forward localhost:8000 to http://mysite.example.com On your server (example.com), we will be listening for tunnel connections on port 15001, and providing an HTTP proxy on port 80. Make sure these are open in your firewall. -Use any port for `--mux-listen-port`, h2tunnel will run an HTTP2 multiplexer on this port bound to 127.0.0.1, -it will not be exposed to the internet even if your firewall allows it. - ```bash +# sudo is required to bind to 0.0.0.0, which makes the tunnel server and proxy server publically available sudo h2tunnel server \ --crt h2tunnel.crt \ --key h2tunnel.key \ - --tunnel-listen-ip 0.0.0.0 \ --tunnel-listen-port 15001 \ - --proxy-listen-ip 0.0.0.0 \ - --proxy-listen-port 80 \ - --mux-listen-port=15002 + --proxy-listen-port 80 ```` On your local machine, we will connect to the tunnel and forward a local HTTP server on port 8000. -Use any port for `--demux-listen-port`, h2tunnel will run an HTTP2 demultiplexer on it. - ```bash -python3 -m http.server # runs on port 8000 +python3 -m http.server # runs on port 8000 by default h2tunnel client \ --crt h2tunnel.crt \ --key h2tunnel.key \ - --tunnel-host=example.com \ + --tunnel-host=mysite.example.com \ --tunnel-port=15001 \ - --local-http-port=8000 \ - --demux-listen-port=15004 + --local-http-port=8000 ``` -### Forward localhost:8000 to https://example.com +### Forward localhost:8000 to https://mysite.example.com This is the same as the previous example, but with an extra layer: a [Caddy](https://caddyserver.com/) reverse proxy -that will auto-provision TLS certificates for your domain. This is useful if you want to expose an HTTPS server. +that will auto-provision TLS certificates for your domain. This is useful if you want to expose a local HTTP server +as HTTPS. The client command line is the same as before, but for the server we will use a docker compose setup. Specify your domain in the `.env` file: ``` -TUNNEL_DOMAIN=example.com +TUNNEL_DOMAIN=mysite.example.com ``` Push the necessary files to the server: @@ -106,8 +128,8 @@ const client = new TunnelClient({ logger: (line) => console.log(line), // optional key: `-----BEGIN PRIVATE KEY----- ...`, cert: `-----BEGIN CERTIFICATE----- ...`, - demuxListenPort: 15004, - localHttpPort: 8000, + originHost: "localhost", // optional + originPort: 8000, tunnelHost: `mysite.example.com`, tunnelPort: 15001, }); @@ -115,6 +137,9 @@ const client = new TunnelClient({ // Start the client client.start(); +// Wait until client is connected +await client.waitUntilConnected(); + // Stop the client await client.stop(); ``` @@ -124,18 +149,23 @@ import {TunnelServer} from "h2tunnel"; const server = new TunnelServer({ logger: (line) => console.log(line), // optional - tunnelListenIp: "0.0.0.0", - tunnelListenPort: 15001, key: `-----BEGIN PRIVATE KEY----- ...`, cert: `-----BEGIN CERTIFICATE----- ...`, + tunnelListenIp: "0.0.0.0", // optional + tunnelListenPort: 15001, + proxyListenIp: "0.0.0.0", // optional proxyListenPort: 80, - proxyListenIp: "0.0.0.0", - muxListenPort: 15002, }); // Start the server server.start(); +// Wait until server is listening +await client.waitUntilListening(); + +// Wait until server is connected +await client.waitUntilConnected(); + // Stop the server await server.stop(); ``` diff --git a/src/cli.ts b/src/cli.ts index 0243518..1de3622 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,12 @@ #!/usr/bin/env node import { parseArgs } from "node:util"; -import { AbstractTunnel, TunnelClient, TunnelServer } from "./h2tunnel.js"; +import { + AbstractTunnel, + DEFAULT_LISTEN_IP, + DEFAULT_ORIGIN_HOST, + TunnelClient, + TunnelServer, +} from "./h2tunnel.js"; import * as fs from "node:fs"; const { positionals, values } = parseArgs({ @@ -24,9 +30,6 @@ const { positionals, values } = parseArgs({ "proxy-listen-port": { type: "string", }, - "mux-listen-port": { - type: "string", - }, // Client "tunnel-host": { type: "string", @@ -34,10 +37,10 @@ const { positionals, values } = parseArgs({ "tunnel-port": { type: "string", }, - "local-http-port": { + "origin-host": { type: "string", }, - "demux-listen-port": { + "origin-port": { type: "string", }, }, @@ -79,17 +82,20 @@ client options: --${"key" satisfies Param} Path to private key file (.key) --${"tunnel-host" satisfies Param} Host for the tunnel server --${"tunnel-port" satisfies Param} Port for the tunnel server - --${"local-http-port" satisfies Param} Port for the local HTTP server - --${"demux-listen-port" satisfies Param} Port for the HTTP2 server to listen on + --${"origin-host" satisfies Param} Host for the local TCP server (default: ${DEFAULT_ORIGIN_HOST}) + --${"origin-port" satisfies Param} Port for the local TCP server server options: --${"crt" satisfies Param} Path to certificate file (.crt) --${"key" satisfies Param} Path to private key file (.key) - --${"tunnel-listen-ip" satisfies Param} IP for the tunnel server to bind on (use 0.0.0.0 for all interfaces) + --${"tunnel-listen-ip" satisfies Param} IP for the tunnel server to bind on (default: ${DEFAULT_LISTEN_IP}) --${"tunnel-listen-port" satisfies Param} Port for the tunnel server to listen on - --${"proxy-listen-ip" satisfies Param} Host for the remote HTTP server (use 0.0.0.0 for all interfaces) - --${"proxy-listen-port" satisfies Param} Port for the remote HTTP server - --${"mux-listen-port" satisfies Param} Port for the HTTP2 server to listen on + --${"proxy-listen-ip" satisfies Param} IP for the remote TCP proxy server to bind on (default: ${DEFAULT_LISTEN_IP}) + --${"proxy-listen-port" satisfies Param} Port for the remote TCP proxy server to listen on + +The tunnel and proxy servers will bind to 0.0.0.0 by default which will make them publically available. This requires +superuser permissions on Linux. You can change this setting to bind to a specific network interface, e.g. a VPN, but +this is advanced usage. `; if (positionals.length === 0) { @@ -99,22 +105,21 @@ if (positionals.length === 0) { let tunnel: AbstractTunnel; if (command === "client") { tunnel = new TunnelClient({ - tunnelHost: getString("tunnel-host"), - tunnelPort: getInt("tunnel-port"), key: fs.readFileSync(getString("key"), "utf8"), cert: fs.readFileSync(getString("crt"), "utf8"), - localHttpPort: getInt("local-http-port"), - demuxListenPort: getInt("demux-listen-port"), + tunnelHost: getString("tunnel-host"), + tunnelPort: getInt("tunnel-port"), + originHost: values["origin-host" satisfies Param], + originPort: getInt("origin-port"), }); } else if (command === "server") { tunnel = new TunnelServer({ - tunnelListenIp: getString("tunnel-listen-ip"), - tunnelListenPort: getInt("tunnel-listen-port"), key: fs.readFileSync(getString("key"), "utf8"), cert: fs.readFileSync(getString("crt"), "utf8"), + tunnelListenIp: values["tunnel-listen-ip" satisfies Param], + tunnelListenPort: getInt("tunnel-listen-port"), + proxyListenIp: values["proxy-listen-ip" satisfies Param], proxyListenPort: getInt("proxy-listen-port"), - proxyListenIp: getString("proxy-listen-ip"), - muxListenPort: getInt("mux-listen-port"), }); } else { throw new Error(`Unknown command: ${command}`); diff --git a/src/h2tunnel.test.ts b/src/h2tunnel.test.ts index 400ef89..610470a 100644 --- a/src/h2tunnel.test.ts +++ b/src/h2tunnel.test.ts @@ -6,12 +6,9 @@ import { TunnelServer, } from "./h2tunnel.js"; import net from "node:net"; -import * as http2 from "node:http2"; -// localhost HTTP1 server "python3 -m http.server" +// localhost echo server const LOCAL_PORT = 14000; -// localhost HTTP2 server that proxies to localhost HTTP1 server -const DEMUX_PORT = 14003; // remote public HTTP1 server const PROXY_PORT = 14004; @@ -19,8 +16,6 @@ const PROXY_TEST_PORT = 14007; // remote TLS server for establishing a tunnel const TUNNEL_PORT = 14005; const TUNNEL_TEST_PORT = 14008; -// remote HTTPS server that is piped through the tunnel to localhost -const MUX_PORT = 14006; // Reduce this to make tests faster const TIME_MULTIPLIER = 0.1; @@ -53,23 +48,22 @@ const getLogger = (name: string, colorCode: number) => (line: object) => const serverOptions: ServerOptions = { logger: getLogger("server", 32), - tunnelListenIp: "127.0.0.1", - tunnelListenPort: TUNNEL_PORT, key: CLIENT_KEY, cert: CLIENT_CRT, - proxyListenPort: PROXY_PORT, + tunnelListenIp: "127.0.0.1", + tunnelListenPort: TUNNEL_PORT, proxyListenIp: "127.0.0.1", - muxListenPort: MUX_PORT, + proxyListenPort: PROXY_PORT, }; const clientOptions: ClientOptions = { logger: getLogger("client", 33), - tunnelHost: "localhost", - tunnelPort: TUNNEL_PORT, key: CLIENT_KEY, cert: CLIENT_CRT, - localHttpPort: LOCAL_PORT, - demuxListenPort: DEMUX_PORT, + tunnelHost: "localhost", + tunnelPort: TUNNEL_PORT, + originHost: "localhost", + originPort: LOCAL_PORT, tunnelRestartTimeout: 500 * TIME_MULTIPLIER, }; diff --git a/src/h2tunnel.ts b/src/h2tunnel.ts index feb35c4..f9d9f20 100644 --- a/src/h2tunnel.ts +++ b/src/h2tunnel.ts @@ -12,17 +12,19 @@ export interface CommonOptions { cert: string; } +export const DEFAULT_LISTEN_IP = "0.0.0.0"; +export const DEFAULT_ORIGIN_HOST = "localhost"; + export interface ServerOptions extends CommonOptions { - tunnelListenIp: string; + tunnelListenIp?: string; tunnelListenPort: number; - proxyListenIp: string; + proxyListenIp?: string; proxyListenPort: number; - muxListenPort: number; } export interface ClientOptions extends CommonOptions { - demuxListenPort: number; - localHttpPort: number; + originHost?: string; + originPort: number; tunnelHost: string; tunnelPort: number; tunnelRestartTimeout?: number; @@ -40,11 +42,11 @@ export abstract class AbstractTunnel< muxSocket: net.Socket | null = null; h2session: S | null = null; abstract init(): void; - constructor( + abstract isListening(): boolean; + protected constructor( readonly log: (line: object) => void = (line) => process.stdout.write(JSON.stringify(line) + "\n"), readonly muxServer: M, - readonly muxListenPort: number, ) { super(); muxServer.maxConnections = 1; @@ -52,7 +54,7 @@ export abstract class AbstractTunnel< this.log({ muxServer: "drop", options }); }); muxServer.on("listening", () => { - this.log({ muxServer: "listening" }); + this.log({ muxServer: "listening", port: this.muxServerPort }); this.updateHook(); }); muxServer.on("error", (err) => { @@ -64,6 +66,10 @@ export abstract class AbstractTunnel< }); } + get muxServerPort(): number { + return (this.muxServer.address() as net.AddressInfo).port; + } + setH2Session(session: S) { this.h2session = session; this.h2session.on("close", () => { @@ -114,24 +120,6 @@ export abstract class AbstractTunnel< this.linkSocketsIfNecessary(); } - updateHook() { - let state: TunnelState; - if (this.aborted) { - state = this.isStopped() ? "stopped" : "stopping"; - } else if (this.h2session && this.tunnelSocket?.readyState === "open") { - state = "connected"; - } else { - this.init(); - state = this.isListening() ? "listening" : "stopped"; - } - - if (state !== this.state) { - this.state = state; - this.log({ state }); - this.emit(state); - } - } - addDemuxSocket(socket: net.Socket, stream: http2.Http2Stream): void { const log = (line: object) => { this.log({ @@ -190,18 +178,38 @@ export abstract class AbstractTunnel< setup(stream, socket); } + updateHook() { + let state: TunnelState; + if (this.aborted) { + state = this.isStopped() ? "stopped" : "stopping"; + } else if (this.h2session && this.tunnelSocket?.readyState === "open") { + state = "connected"; + } else if (this.isListening()) { + if (this.muxServer.listening) { + this.init(); + state = "listening"; + } else { + this.log({ muxServer: "starting" }); + this.muxServer.listen(); // Let the OS pick a port + state = "stopped"; + } + } else { + state = "stopped"; + } + + if (state !== this.state) { + this.state = state; + this.log({ state }); + this.emit(state); + } + } + start() { this.log({ starting: true, pid: process.pid }); - this.log({ muxServer: "starting" }); - this.muxServer.listen(this.muxListenPort); this.aborted = false; this.updateHook(); } - isListening() { - return this.muxServer.listening; - } - isStopped() { return ( !this.h2session && @@ -253,7 +261,7 @@ export class TunnelServer extends AbstractTunnel< }), readonly proxyServer = net.createServer({ allowHalfOpen: true }), ) { - super(options.logger, net.createServer(), options.muxListenPort); + super(options.logger, net.createServer()); this.muxServer.on("connection", (socket: net.Socket) => { this.log({ muxServer: "connection" }); this.setMuxSocket(socket); @@ -308,12 +316,12 @@ export class TunnelServer extends AbstractTunnel< this.log({ proxyServer: "starting" }); this.proxyServer.listen( this.options.proxyListenPort, - this.options.proxyListenIp, + this.options.proxyListenIp ?? DEFAULT_LISTEN_IP, ); this.log({ tunnelServer: "starting" }); this.tunnelServer.listen( this.options.tunnelListenPort, - this.options.tunnelListenIp, + this.options.tunnelListenIp ?? DEFAULT_LISTEN_IP, ); super.start(); } @@ -333,19 +341,13 @@ export class TunnelServer extends AbstractTunnel< } isListening() { - return ( - super.isListening() && - this.proxyServer.listening && - this.tunnelServer.listening - ); + return this.proxyServer.listening && this.tunnelServer.listening; } init() { if (this.tunnelSocket && !this.tunnelSocket.closed && !this.h2session) { this.log({ muxSession: "starting" }); - const session = http2.connect( - `http://localhost:${this.options.muxListenPort}`, - ); + const session = http2.connect(`http://localhost:${this.muxServerPort}`); session.on("connect", () => { this.log({ h2session: "connect" }); this.updateHook(); @@ -367,15 +369,15 @@ export class TunnelClient extends AbstractTunnel< tunnelSocketRestartTimeout: NodeJS.Timeout | null = null; constructor(readonly options: ClientOptions) { - super(options.logger, http2.createServer(), options.demuxListenPort); + super(options.logger, http2.createServer()); this.muxServer.on("session", (session: http2.ServerHttp2Session) => { this.log({ muxServer: "session" }); this.setH2Session(session); session.on("stream", (stream: http2.ServerHttp2Stream) => { this.addDemuxSocket( net.createConnection({ - host: "127.0.0.1", - port: this.options.localHttpPort, + host: this.options.originHost ?? DEFAULT_ORIGIN_HOST, + port: this.options.originPort, allowHalfOpen: true, }), stream, @@ -403,6 +405,10 @@ export class TunnelClient extends AbstractTunnel< this.setTunnelSocket(socket); } + isListening() { + return true; + } + onAbort() { if (this.tunnelSocketRestartTimeout) { clearTimeout(this.tunnelSocketRestartTimeout); @@ -415,7 +421,7 @@ export class TunnelClient extends AbstractTunnel< if (!this.muxSocket) { const muxSocket = net.createConnection({ host: "localhost", - port: this.options.demuxListenPort, + port: this.muxServerPort, }); muxSocket.on("connect", () => { this.log({ muxSocket: "connect" });