Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions apps/server/src/cli/sshHelper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import * as NodeNet from "node:net";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/process";

// On macOS, Node can bind 127.0.0.1:<port> while another process (such as the
// desktop app with network access on) listens on *:<port>. Linux refuses that
// bind unless both sockets set SO_REUSEPORT, so on Linux the preload adds it to
// the picker's probe and the holder sets it too. macOS needs neither.
const MACOS_BIND_PRELOAD = `const net = require("node:net");
const listen = net.Server.prototype.listen;
net.Server.prototype.listen = function (port, host, callback) {
if (typeof port !== "number" || typeof host !== "string") return listen.apply(this, arguments);
return listen.call(this, { port, host, reusePort: true }, callback);
};
`;

describe("__ssh-helper pick-port", () => {
const hostPlatform = HostProcess.Platform.defaultValue();
const linuxHost = hostPlatform === "linux";
const windowsHost = hostPlatform === "win32";

const holdWildcardPort = Effect.acquireRelease(
Effect.callback<NodeNet.Server>((resume) => {
const server = NodeNet.createServer((socket) => socket.destroy());
server.once("error", (error) => resume(Effect.die(error)));
server.listen({ port: 0, host: "0.0.0.0", reusePort: linuxHost }, () =>
resume(Effect.succeed(server)),
);
}),
(server) =>
Effect.callback<void>((resume) => {
server.close(() => resume(Effect.void));
}),
);

it.effect.skipIf(windowsHost)(
"skips a port another server answers on loopback",
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pick-port-" });
yield* fs.writeFileString(`${root}/preload.cjs`, MACOS_BIND_PRELOAD);
const holder = yield* holdWildcardPort;
const heldPort = (holder.address() as NodeNet.AddressInfo).port;

const child = yield* spawner.spawn(
ChildProcess.make(process.execPath, [
...(linuxHost ? ["--require", `${root}/preload.cjs`] : []),
"--experimental-strip-types",
new URL("../bin.ts", import.meta.url).pathname,
"__ssh-helper",
"pick-port",
"",
String(heldPort),
"20",
]),
);
const [stdout, exitCode] = yield* Effect.all(
[
child.stdout.pipe(
Stream.decodeText(),
Stream.runFold(
() => "",
(acc, chunk) => acc + chunk,
),
),
child.exitCode.pipe(Effect.map(Number)),
],
{ concurrency: "unbounded" },
);

assert.equal(exitCode, 0);
const picked = Number(stdout);
assert.isAbove(picked, heldPort);
assert.isBelow(picked, heldPort + 20);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
30_000,
);
});
25 changes: 21 additions & 4 deletions apps/server/src/cli/sshHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,31 @@ import { Argument, Command } from "effect/cli";
* them.
*/

const tryPort = (port: number) =>
new Promise<number | false>((resolve) => {
const canBind = (port: number) =>
new Promise<boolean>((resolve) => {
const server = NodeNet.createServer();
server.unref();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
server.close((error) => resolve(error ? false : port));
server.close((error) => resolve(!error));
});
});

// macOS lets the bind above succeed while another process listens on
// *:<port>, so also skip any port that already answers on loopback.
const answersOnLoopback = (port: number) =>
new Promise<boolean>((resolve) => {
const socket = NodeNet.connect({ port, host: "127.0.0.1" });
socket.setTimeout(1000);
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("timeout", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => resolve(false));
});

/** Prints the first free loopback port from the preferred one, scanning `window` ports. */
Expand All @@ -40,7 +57,7 @@ const pickPort = Command.make("pick-port", {
const preferred = Number.parseInt(raw, 10);
const start = Number.isInteger(preferred) ? preferred : defaultPort;
for (let port = start; port < start + scanWindow; port += 1) {
if (await tryPort(port)) {
if ((await canBind(port)) && !(await answersOnLoopback(port))) {
process.stdout.write(String(port));
return;
}
Expand Down
74 changes: 74 additions & 0 deletions packages/ssh/src/tunnel.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as NodeNet from "node:net";

import { assert, describe, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NetService from "@t3tools/shared/Net";
Expand Down Expand Up @@ -693,6 +695,78 @@ describe("ssh tunnel scripts", () => {
);
});

// On macOS, Node can bind 127.0.0.1:<port> while another process (such as the
// desktop app with network access on) listens on *:<port>. Linux refuses that
// bind unless both sockets set SO_REUSEPORT, so on Linux the preload adds it to
// the picker's probe and the holder sets it too. macOS needs neither.
const MACOS_BIND_PRELOAD = `const net = require("node:net");
const listen = net.Server.prototype.listen;
net.Server.prototype.listen = function (port, host, callback) {
if (typeof port !== "number" || typeof host !== "string") return listen.apply(this, arguments);
return listen.call(this, { port, host, reusePort: true }, callback);
};
`;

describe("remote port picker", () => {
const hostPlatform = HostProcess.Platform.defaultValue();
const linuxHost = hostPlatform === "linux";
const windowsHost = hostPlatform === "win32";

const holdWildcardPort = Effect.acquireRelease(
Effect.callback<NodeNet.Server>((resume) => {
const server = NodeNet.createServer((socket) => socket.destroy());
server.once("error", (error) => resume(Effect.die(error)));
server.listen({ port: 0, host: "0.0.0.0", reusePort: linuxHost }, () =>
resume(Effect.succeed(server)),
);
}),
(server) =>
Effect.callback<void>((resume) => {
server.close(() => resume(Effect.void));
}),
);

it.effect.skipIf(windowsHost)("skips a port another server answers on loopback", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pick-port-" });
yield* fs.writeFileString(`${root}/preload.cjs`, MACOS_BIND_PRELOAD);
yield* fs.writeFileString(`${root}/pick-port.cjs`, SshTunnel.REMOTE_PICK_PORT_SCRIPT);
const holder = yield* holdWildcardPort;
const heldPort = (holder.address() as NodeNet.AddressInfo).port;

const child = yield* spawner.spawn(
ChildProcess.make(process.execPath, [
...(linuxHost ? ["--require", `${root}/preload.cjs`] : []),
`${root}/pick-port.cjs`,
"",
String(heldPort),
"20",
]),
);
const [stdout, exitCode] = yield* Effect.all(
[
child.stdout.pipe(
Stream.decodeText(),
Stream.runFold(
() => "",
(acc, chunk) => acc + chunk,
),
),
child.exitCode.pipe(Effect.map(Number)),
],
{ concurrency: "unbounded" },
);

assert.equal(exitCode, 0);
const picked = Number(stdout);
assert.isAbove(picked, heldPort);
assert.isBelow(picked, heldPort + 20);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
});

// The archive runner is generated shell; string assertions cannot prove the
// lock excludes concurrent installers. Run the real script against a tiny
// fake archive served from a file:// mirror.
Expand Down
24 changes: 21 additions & 3 deletions packages/ssh/src/tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,20 +260,38 @@ const preferred = Number.parseInt(raw, 10);
const start = Number.isInteger(preferred) ? preferred : defaultPort;
const end = start + scanWindow;

function tryPort(port) {
function canBind(port) {
return new Promise((resolve) => {
const server = net.createServer();
server.unref();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
server.close((error) => resolve(error ? false : port));
server.close((error) => resolve(!error));
});
});
}

// macOS lets the bind above succeed while another process listens on
// *:<port>, so also skip any port that already answers on loopback.
function answersOnLoopback(port) {
return new Promise((resolve) => {
const socket = net.connect({ port, host: "127.0.0.1" });
socket.setTimeout(1000);
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("timeout", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => resolve(false));
});
}

(async () => {
for (let port = start; port < end; port += 1) {
const available = await tryPort(port);
const available = (await canBind(port)) && !(await answersOnLoopback(port));
if (available) {
process.stdout.write(String(port));
return;
Expand Down
Loading