Skip to content

Commit

Permalink
Support half-open TCP connections
Browse files Browse the repository at this point in the history
  • Loading branch information
boronine committed Nov 3, 2024
1 parent f84a580 commit 1af631d
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 181 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/boronine/h2tunnel/node.js.yml)](https://github.com/boronine/h2tunnel/actions/workflows/node.js.yml)

A CLI tool and Node.js library for a popular "tunneling" workflow, similar to the proprietary [ngrok](https://ngrok.com/)
or the openssh-based `ssh -L` solution. All in [less than 500 LOC](https://github.com/boronine/h2tunnel/blob/main/src/h2tunnel.ts)
or the openssh-based `ssh -R` solution. All in [less than 500 LOC](https://github.com/boronine/h2tunnel/blob/main/src/h2tunnel.ts)
with no dependencies.

![Diagram](https://raw.githubusercontent.com/boronine/h2tunnel/main/diagram.drawio.svg)
Expand All @@ -17,7 +17,7 @@ to the server, and the server proxies requests through this tunnel to your local

## How does h2tunnel work?

1. The client initiates a TLS connection to the server and starts listening for HTTP2 sessions on it
1. The client initiates a TLS connection to the server and starts listening for HTTP2 sessions
2. The server takes the newly created TLS socket and initiates an HTTP2 session through it
3. The server starts accepting TCP connections, converting them into HTTP2 streams, and fowarding them to the client
4. The client receives these HTTP2 streams and converts them back into TCP connections to feed them into the local server
Expand All @@ -33,7 +33,7 @@ the server, and both are configured to reject anything else. This way, the pair
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=example.com"
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
Expand Down
275 changes: 138 additions & 137 deletions src/h2tunnel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import {
TunnelServer,
} from "./h2tunnel.js";
import net from "node:net";
import * as http2 from "node:http2";
import { strictEqual } from "node:assert";

// localhost HTTP1 server "python3 -m http.server"
const LOCAL_PORT = 14000;
Expand Down Expand Up @@ -122,7 +120,7 @@ class NetworkEmulator {
constructor(
readonly originPort: number,
readonly proxyPort: number,
readonly server = net.createServer(),
readonly server = net.createServer({ allowHalfOpen: true }),
readonly logger = getLogger("network", 31),
readonly abortController = new AbortController(),
) {}
Expand All @@ -134,6 +132,7 @@ class NetworkEmulator {
const outgoingSocket = net.createConnection({
host: "127.0.0.1",
port: this.originPort,
allowHalfOpen: true,
});
this.outgoingSocket = outgoingSocket;
outgoingSocket.on("error", () => incomingSocket.resetAndDestroy());
Expand Down Expand Up @@ -161,7 +160,7 @@ class EchoServer {
public proxyPort = port,
readonly logger = getLogger("localhost", 35),
) {
const server = net.createServer();
const server = net.createServer({ allowHalfOpen: true });
server.on("connection", (socket) => {
logger({ echoServer: "connection" });
socket.on("error", (err) => {
Expand All @@ -172,24 +171,27 @@ class EchoServer {
echoServerData: data.toString(),
socketWritableEnded: socket.writableEnded,
});
// Add to data received
const previousData = this.dataReceived.get(socket) ?? "";
this.dataReceived.set(socket, previousData + data.toString("utf-8"));

this.appendData(socket, data);
if (!socket.writableEnded) {
socket.write(data);
}
});
// Make sure other end stays half-open long enough to receive the last byte
socket.on("end", async () => {
logger({ echoServer: "received FIN" });
await sleep(50);
logger({ echoServer: "sending last byte" });
socket.end("z");
});
});

this.server = server;
}

// reset(proxyPort: number) {
// this.proxyPort = proxyPort;
// this.dataReceived.clear();
// this.i = 0;
// }
appendData(socket: net.Socket, data: Buffer): void {
const previousData = this.dataReceived.get(socket) ?? "";
this.dataReceived.set(socket, previousData + data.toString("utf-8"));
}

getSocketByPrefix(prefix: string): net.Socket {
for (const [socket, data] of this.dataReceived) {
Expand All @@ -215,7 +217,19 @@ class EchoServer {
}

createClientSocket(): net.Socket {
return net.createConnection(this.proxyPort);
const socket = net.createConnection({
port: this.proxyPort,
allowHalfOpen: true,
});
socket.on("data", (chunk) => {
this.appendData(socket, chunk);
});
// Make sure other end stays half-open long enough to receive the last byte
socket.on("end", async () => {
await sleep(50);
socket.end("z");
});
return socket;
}

async expectEconn() {
Expand Down Expand Up @@ -268,148 +282,135 @@ async function testConn(
term: "FIN" | "RST",
by: "client" | "server",
delay: number = 0,
strict = true,
) {
await sleep(delay);
const conn = await server.createConn(t);
await t.test(
`ping pong ${numBytes} byte(s)`,
{ plan: numBytes },
async (t: TestContext) => {
for (let i = 0; i < numBytes; i++) {
await new Promise<void>((resolve) => {
conn.originSocket.once("data", (pong) => {
t.assert.strictEqual(pong.toString(), "a");
resolve();
});
// ping
const ping = "a";
conn.clientSocket.write(ping);
});
await sleep(50);
}
},
);
for (let i = 0; i < numBytes; i++) {
await new Promise<void>((resolve) => {
conn.originSocket.once("data", (pong) => {
t.assert.strictEqual(pong.toString(), "a");
resolve();
});
conn.clientSocket.write("a");
});
await sleep(50);
}

const [socket1, socket2] =
by === "client"
? [conn.clientSocket, conn.originSocket]
: [conn.originSocket, conn.clientSocket];

if (term === "FIN") {
await t.test(
`clean termination by ${by} FIN`,
{ plan: 12, timeout: 1000 },
(t: TestContext) =>
new Promise<void>((resolve, reject) => {
let i = 0;
const done = () => i === 2 && resolve();
t.assert.strictEqual(socket2.readyState, "open");
t.assert.strictEqual(socket1.readyState, "open");
socket2.on("end", () => {
// Server sent FIN and client received it
t.assert.strictEqual(socket2.readyState, "writeOnly");
t.assert.strictEqual(
socket1.readyState,
strict ? "readOnly" : "closed",
);
});
socket2.on("close", (hasError) => {
t.assert.strictEqual(hasError, false);
t.assert.strictEqual(socket2.errored, null);
t.assert.strictEqual(socket2.readyState, "closed");
i++;
done();
});
socket1.on("close", (hasError) => {
t.assert.strictEqual(hasError, false);
t.assert.strictEqual(socket1.errored, null);
t.assert.strictEqual(socket1.readyState, "closed");
i++;
done();
});
socket1.end();
// Server sent FIN, but client didn't receive it yet
t.assert.strictEqual(socket2.readyState, "open");
const promise = Promise.all([
new Promise<void>((resolve) => {
socket2.on("end", () => {
// socket1 sent FIN and socket2 received it
t.assert.strictEqual(socket2.readyState, "writeOnly");
t.assert.strictEqual(socket1.readyState, "readOnly");
}),
);
resolve();
});
}),
new Promise<void>((resolve) => {
socket2.on("close", (hasError) => {
t.assert.strictEqual(hasError, false);
t.assert.strictEqual(socket2.errored, null);
t.assert.strictEqual(socket2.readyState, "closed");
resolve();
});
}),
new Promise<void>((resolve) => {
socket1.on("close", (hasError) => {
t.assert.strictEqual(hasError, false);
t.assert.strictEqual(socket1.errored, null);
t.assert.strictEqual(socket1.readyState, "closed");
resolve();
});
}),
]);
t.assert.strictEqual(socket2.readyState, "open");
t.assert.strictEqual(socket1.readyState, "open");
socket1.end();
// socket1 sent FIN, but socket2 didn't receive it yet
t.assert.strictEqual(socket2.readyState, "open");
t.assert.strictEqual(socket1.readyState, "readOnly");
await promise;
const socket1data = server.dataReceived.get(socket1);
const socket2data = server.dataReceived.get(socket2);
// Make sure last byte was successfully communicated in half-open state
t.assert.strictEqual(socket1data, socket2data + "z");
} else if (term == "RST") {
await t.test(
`clean reset by ${by} RST`,
{ plan: 8, timeout: 1000 },
(t: TestContext) =>
new Promise<void>((resolve) => {
let i = 0;
const done = () => i === 2 && resolve();
socket2.on("error", (err) => {
t.assert.strictEqual(err["code"], "ECONNRESET");
t.assert.strictEqual(socket2.readyState, "closed");
t.assert.strictEqual(socket2.destroyed, true);
i++;
done();
});
socket1.on("close", (hasError) => {
// No error on our end because we initiated the RST
t.assert.strictEqual(hasError, false);
t.assert.strictEqual(socket1.readyState, "closed");
t.assert.strictEqual(socket1.destroyed, true);
i++;
done();
});
socket1.resetAndDestroy();
socket1.resetAndDestroy();
t.assert.strictEqual(socket1.readyState, "closed");
t.assert.strictEqual(socket2.readyState, "open");
await Promise.all([
new Promise<void>((resolve) => {
socket2.on("error", (err) => {
t.assert.strictEqual(err["code"], "ECONNRESET");
t.assert.strictEqual(socket2.readyState, "closed");
t.assert.strictEqual(socket2.destroyed, true);
resolve();
});
}),
new Promise<void>((resolve) => {
socket1.on("close", (hasError) => {
// No error on our end because we initiated the RST
t.assert.strictEqual(hasError, false);
t.assert.strictEqual(socket1.readyState, "closed");
t.assert.strictEqual(socket2.readyState, "open");
}),
);
t.assert.strictEqual(socket1.destroyed, true);
resolve();
});
}),
]);
}
}

await test.only("basic connection and termination", async (t) => {
const net = new NetworkEmulator(LOCAL_PORT, PROXY_TEST_PORT);
const server = new TunnelServer(serverOptions);
const client = new TunnelClient(clientOptions);
server.start();
client.start();
await server.waitUntilListening();
await client.waitUntilConnected();
await server.waitUntilConnected();
console.log(0, client.state);
await net.startAndWaitUntilReady();
for (const term of ["FIN", "RST"] satisfies ("FIN" | "RST")[]) {
for (const by of ["client", "server"] satisfies ("client" | "server")[]) {
for (const proxyPort of [LOCAL_PORT, PROXY_TEST_PORT, PROXY_PORT]) {
await t.test(
`clean termination by ${by} ${term} on ${proxyPort}`,
async (t) => {
const echoServer = new EchoServer(LOCAL_PORT, proxyPort);
await echoServer.startAndWaitUntilReady();
const strict = proxyPort !== PROXY_PORT;
// Test single
await testConn(t, echoServer, 1, term, by, 0, strict);
await testConn(t, echoServer, 4, term, by, 0, strict);
// Test double simultaneous
await Promise.all([
testConn(t, echoServer, 3, term, by, 0, strict),
testConn(t, echoServer, 3, term, by, 0, strict),
]);
// Test triple delayed
await Promise.all([
testConn(t, echoServer, 4, term, by, 0, strict),
testConn(t, echoServer, 4, term, by, 10, strict),
testConn(t, echoServer, 4, term, by, 100, strict),
]);
await echoServer.stopAndWaitUntilClosed();
},
);
await test(
"basic connection and termination",
{ timeout: 10000 },
async (t) => {
const net = new NetworkEmulator(LOCAL_PORT, PROXY_TEST_PORT);
const server = new TunnelServer(serverOptions);
const client = new TunnelClient(clientOptions);
server.start();
client.start();
await server.waitUntilListening();
await client.waitUntilConnected();
await server.waitUntilConnected();
await net.startAndWaitUntilReady();
for (const term of ["FIN", "RST"] satisfies ("FIN" | "RST")[]) {
for (const by of ["client", "server"] satisfies ("client" | "server")[]) {
for (const proxyPort of [LOCAL_PORT, PROXY_TEST_PORT, PROXY_PORT]) {
// if (term !== "FIN" || by !== "client") {
// continue;
// }
console.log(`clean termination by ${by} ${term} on ${proxyPort}`);
const echoServer = new EchoServer(LOCAL_PORT, proxyPort);
await echoServer.startAndWaitUntilReady();
// Test single
await testConn(t, echoServer, 1, term, by, 0);
await testConn(t, echoServer, 4, term, by, 0);
// Test double simultaneous
await Promise.all([
testConn(t, echoServer, 3, term, by, 0),
testConn(t, echoServer, 3, term, by, 0),
]);
// Test triple delayed
await Promise.all([
testConn(t, echoServer, 4, term, by, 0),
testConn(t, echoServer, 4, term, by, 10),
testConn(t, echoServer, 4, term, by, 100),
]);
await echoServer.stopAndWaitUntilClosed();
}
}
}
}

await net.stopAndWaitUntilClosed();
await client.stop();
await server.stop();
});
await net.stopAndWaitUntilClosed();
await client.stop();
await server.stop();
},
);

await test("happy-path", async (t) => {
const echo = new EchoServer(LOCAL_PORT, PROXY_PORT);
Expand Down
Loading

0 comments on commit 1af631d

Please sign in to comment.