diff --git a/apps/server/src/cli/sshHelper.test.ts b/apps/server/src/cli/sshHelper.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..54ec1a2f2a8715e774af35e0cc60946207b82e22 --- /dev/null +++ b/apps/server/src/cli/sshHelper.test.ts @@ -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: while another process (such as the +// desktop app with network access on) listens on *:. 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((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((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, + ); +}); diff --git a/apps/server/src/cli/sshHelper.ts b/apps/server/src/cli/sshHelper.ts index 09538c7df22d8638fd78e21eaa4708e8938c87e3..63e3461b0a8f75543e3fa46c115596cbc4a6fc26 100644 --- a/apps/server/src/cli/sshHelper.ts +++ b/apps/server/src/cli/sshHelper.ts @@ -18,16 +18,33 @@ import { Argument, Command } from "effect/cli"; * them. */ -const tryPort = (port: number) => - new Promise((resolve) => { +const canBind = (port: number) => + new Promise((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 +// *:, so also skip any port that already answers on loopback. +const answersOnLoopback = (port: number) => + new Promise((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. */ const pickPort = Command.make("pick-port", { portFile: Argument.String("port-file"), @@ -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; } diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 1ebb1ff5d3a35cc47e0783413d1f077e702b248a..c6fb91c6a94723ef6e0df1c04265d7951a6c68c3 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -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"; @@ -693,6 +695,78 @@ describe("ssh tunnel scripts", () => { ); }); +// On macOS, Node can bind 127.0.0.1: while another process (such as the +// desktop app with network access on) listens on *:. 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((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((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. diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index c13e8d3768fa0d64744bff785fd670ccf524ed5a..abce5b13efb16f5ba9ceb8d8f431e2016d1bcd0c 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -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 +// *:, 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;