| author | |
| committer | |
| log | 9688e74112af93147a19499260dfaaa3f2517e0f |
| tree | 9d83ca5cb20b605818db647623b56509bc887ceb |
| parent | 6c142934188fa83bcc1d9cb44b425ced82d66468 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>4 files changed, 202 insertions(+), 7 deletions(-)
apps/server/src/cli/sshHelper.test.ts created+86| ... | ... | @@ -0,0 +1,86 @@ |
| 1 | import * as NodeNet from "node:net"; | |
| 2 | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { assert, describe, it } from "@effect/vitest"; | |
| 5 | import * as HostProcess from "@t3tools/shared/HostProcess"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | import * as Stream from "effect/Stream"; | |
| 9 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 10 | ||
| 11 | // On macOS, Node can bind 127.0.0.1:<port> while another process (such as the | |
| 12 | // desktop app with network access on) listens on *:<port>. Linux refuses that | |
| 13 | // bind unless both sockets set SO_REUSEPORT, so on Linux the preload adds it to | |
| 14 | // the picker's probe and the holder sets it too. macOS needs neither. | |
| 15 | const MACOS_BIND_PRELOAD = `const net = require("node:net"); | |
| 16 | const listen = net.Server.prototype.listen; | |
| 17 | net.Server.prototype.listen = function (port, host, callback) { | |
| 18 | if (typeof port !== "number" || typeof host !== "string") return listen.apply(this, arguments); | |
| 19 | return listen.call(this, { port, host, reusePort: true }, callback); | |
| 20 | }; | |
| 21 | `; | |
| 22 | ||
| 23 | describe("__ssh-helper pick-port", () => { | |
| 24 | const hostPlatform = HostProcess.Platform.defaultValue(); | |
| 25 | const linuxHost = hostPlatform === "linux"; | |
| 26 | const windowsHost = hostPlatform === "win32"; | |
| 27 | ||
| 28 | const holdWildcardPort = Effect.acquireRelease( | |
| 29 | Effect.callback<NodeNet.Server>((resume) => { | |
| 30 | const server = NodeNet.createServer((socket) => socket.destroy()); | |
| 31 | server.once("error", (error) => resume(Effect.die(error))); | |
| 32 | server.listen({ port: 0, host: "0.0.0.0", reusePort: linuxHost }, () => | |
| 33 | resume(Effect.succeed(server)), | |
| 34 | ); | |
| 35 | }), | |
| 36 | (server) => | |
| 37 | Effect.callback<void>((resume) => { | |
| 38 | server.close(() => resume(Effect.void)); | |
| 39 | }), | |
| 40 | ); | |
| 41 | ||
| 42 | it.effect.skipIf(windowsHost)( | |
| 43 | "skips a port another server answers on loopback", | |
| 44 | () => | |
| 45 | Effect.gen(function* () { | |
| 46 | const fs = yield* FileSystem.FileSystem; | |
| 47 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 48 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pick-port-" }); | |
| 49 | yield* fs.writeFileString(`${root}/preload.cjs`, MACOS_BIND_PRELOAD); | |
| 50 | const holder = yield* holdWildcardPort; | |
| 51 | const heldPort = (holder.address() as NodeNet.AddressInfo).port; | |
| 52 | ||
| 53 | const child = yield* spawner.spawn( | |
| 54 | ChildProcess.make(process.execPath, [ | |
| 55 | ...(linuxHost ? ["--require", `${root}/preload.cjs`] : []), | |
| 56 | "--experimental-strip-types", | |
| 57 | new URL("../bin.ts", import.meta.url).pathname, | |
| 58 | "__ssh-helper", | |
| 59 | "pick-port", | |
| 60 | "", | |
| 61 | String(heldPort), | |
| 62 | "20", | |
| 63 | ]), | |
| 64 | ); | |
| 65 | const [stdout, exitCode] = yield* Effect.all( | |
| 66 | [ | |
| 67 | child.stdout.pipe( | |
| 68 | Stream.decodeText(), | |
| 69 | Stream.runFold( | |
| 70 | () => "", | |
| 71 | (acc, chunk) => acc + chunk, | |
| 72 | ), | |
| 73 | ), | |
| 74 | child.exitCode.pipe(Effect.map(Number)), | |
| 75 | ], | |
| 76 | { concurrency: "unbounded" }, | |
| 77 | ); | |
| 78 | ||
| 79 | assert.equal(exitCode, 0); | |
| 80 | const picked = Number(stdout); | |
| 81 | assert.isAbove(picked, heldPort); | |
| 82 | assert.isBelow(picked, heldPort + 20); | |
| 83 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 84 | 30_000, | |
| 85 | ); | |
| 86 | }); |
apps/server/src/cli/sshHelper.ts+21-4| ... | ... | @@ -18,14 +18,31 @@ import { Argument, Command } from "effect/cli"; |
| 18 | 18 | * them. |
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | const tryPort = (port: number) => | |
| 22 | new Promise<number | false>((resolve) => { | |
| 21 | const canBind = (port: number) => | |
| 22 | new Promise<boolean>((resolve) => { | |
| 23 | 23 | const server = NodeNet.createServer(); |
| 24 | 24 | server.unref(); |
| 25 | 25 | server.once("error", () => resolve(false)); |
| 26 | 26 | server.listen(port, "127.0.0.1", () => { |
| 27 | server.close((error) => resolve(error ? false : port)); | |
| 27 | server.close((error) => resolve(!error)); | |
| 28 | }); | |
| 29 | }); | |
| 30 | ||
| 31 | // macOS lets the bind above succeed while another process listens on | |
| 32 | // *:<port>, so also skip any port that already answers on loopback. | |
| 33 | const answersOnLoopback = (port: number) => | |
| 34 | new Promise<boolean>((resolve) => { | |
| 35 | const socket = NodeNet.connect({ port, host: "127.0.0.1" }); | |
| 36 | socket.setTimeout(1000); | |
| 37 | socket.once("connect", () => { | |
| 38 | socket.destroy(); | |
| 39 | resolve(true); | |
| 40 | }); | |
| 41 | socket.once("timeout", () => { | |
| 42 | socket.destroy(); | |
| 43 | resolve(true); | |
| 28 | 44 | }); |
| 45 | socket.once("error", () => resolve(false)); | |
| 29 | 46 | }); |
| 30 | 47 | |
| 31 | 48 | /** Prints the first free loopback port from the preferred one, scanning `window` ports. */ |
| ... | ... | @@ -40,7 +57,7 @@ const pickPort = Command.make("pick-port", { |
| 40 | 57 | const preferred = Number.parseInt(raw, 10); |
| 41 | 58 | const start = Number.isInteger(preferred) ? preferred : defaultPort; |
| 42 | 59 | for (let port = start; port < start + scanWindow; port += 1) { |
| 43 | if (await tryPort(port)) { | |
| 60 | if ((await canBind(port)) && !(await answersOnLoopback(port))) { | |
| 44 | 61 | process.stdout.write(String(port)); |
| 45 | 62 | return; |
| 46 | 63 | } |
packages/ssh/src/tunnel.test.ts+74| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import * as NodeNet from "node:net"; | |
| 2 | ||
| 1 | 3 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 5 | import * as NetService from "@t3tools/shared/Net"; |
| ... | ... | @@ -693,6 +695,78 @@ describe("ssh tunnel scripts", () => { |
| 693 | 695 | ); |
| 694 | 696 | }); |
| 695 | 697 | |
| 698 | // On macOS, Node can bind 127.0.0.1:<port> while another process (such as the | |
| 699 | // desktop app with network access on) listens on *:<port>. Linux refuses that | |
| 700 | // bind unless both sockets set SO_REUSEPORT, so on Linux the preload adds it to | |
| 701 | // the picker's probe and the holder sets it too. macOS needs neither. | |
| 702 | const MACOS_BIND_PRELOAD = `const net = require("node:net"); | |
| 703 | const listen = net.Server.prototype.listen; | |
| 704 | net.Server.prototype.listen = function (port, host, callback) { | |
| 705 | if (typeof port !== "number" || typeof host !== "string") return listen.apply(this, arguments); | |
| 706 | return listen.call(this, { port, host, reusePort: true }, callback); | |
| 707 | }; | |
| 708 | `; | |
| 709 | ||
| 710 | describe("remote port picker", () => { | |
| 711 | const hostPlatform = HostProcess.Platform.defaultValue(); | |
| 712 | const linuxHost = hostPlatform === "linux"; | |
| 713 | const windowsHost = hostPlatform === "win32"; | |
| 714 | ||
| 715 | const holdWildcardPort = Effect.acquireRelease( | |
| 716 | Effect.callback<NodeNet.Server>((resume) => { | |
| 717 | const server = NodeNet.createServer((socket) => socket.destroy()); | |
| 718 | server.once("error", (error) => resume(Effect.die(error))); | |
| 719 | server.listen({ port: 0, host: "0.0.0.0", reusePort: linuxHost }, () => | |
| 720 | resume(Effect.succeed(server)), | |
| 721 | ); | |
| 722 | }), | |
| 723 | (server) => | |
| 724 | Effect.callback<void>((resume) => { | |
| 725 | server.close(() => resume(Effect.void)); | |
| 726 | }), | |
| 727 | ); | |
| 728 | ||
| 729 | it.effect.skipIf(windowsHost)("skips a port another server answers on loopback", () => | |
| 730 | Effect.gen(function* () { | |
| 731 | const fs = yield* FileSystem.FileSystem; | |
| 732 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 733 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pick-port-" }); | |
| 734 | yield* fs.writeFileString(`${root}/preload.cjs`, MACOS_BIND_PRELOAD); | |
| 735 | yield* fs.writeFileString(`${root}/pick-port.cjs`, SshTunnel.REMOTE_PICK_PORT_SCRIPT); | |
| 736 | const holder = yield* holdWildcardPort; | |
| 737 | const heldPort = (holder.address() as NodeNet.AddressInfo).port; | |
| 738 | ||
| 739 | const child = yield* spawner.spawn( | |
| 740 | ChildProcess.make(process.execPath, [ | |
| 741 | ...(linuxHost ? ["--require", `${root}/preload.cjs`] : []), | |
| 742 | `${root}/pick-port.cjs`, | |
| 743 | "", | |
| 744 | String(heldPort), | |
| 745 | "20", | |
| 746 | ]), | |
| 747 | ); | |
| 748 | const [stdout, exitCode] = yield* Effect.all( | |
| 749 | [ | |
| 750 | child.stdout.pipe( | |
| 751 | Stream.decodeText(), | |
| 752 | Stream.runFold( | |
| 753 | () => "", | |
| 754 | (acc, chunk) => acc + chunk, | |
| 755 | ), | |
| 756 | ), | |
| 757 | child.exitCode.pipe(Effect.map(Number)), | |
| 758 | ], | |
| 759 | { concurrency: "unbounded" }, | |
| 760 | ); | |
| 761 | ||
| 762 | assert.equal(exitCode, 0); | |
| 763 | const picked = Number(stdout); | |
| 764 | assert.isAbove(picked, heldPort); | |
| 765 | assert.isBelow(picked, heldPort + 20); | |
| 766 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 767 | ); | |
| 768 | }); | |
| 769 | ||
| 696 | 770 | // The archive runner is generated shell; string assertions cannot prove the |
| 697 | 771 | // lock excludes concurrent installers. Run the real script against a tiny |
| 698 | 772 | // fake archive served from a file:// mirror. |
packages/ssh/src/tunnel.ts+21-3| ... | ... | @@ -260,20 +260,38 @@ const preferred = Number.parseInt(raw, 10); |
| 260 | 260 | const start = Number.isInteger(preferred) ? preferred : defaultPort; |
| 261 | 261 | const end = start + scanWindow; |
| 262 | 262 | |
| 263 | function tryPort(port) { | |
| 263 | function canBind(port) { | |
| 264 | 264 | return new Promise((resolve) => { |
| 265 | 265 | const server = net.createServer(); |
| 266 | 266 | server.unref(); |
| 267 | 267 | server.once("error", () => resolve(false)); |
| 268 | 268 | server.listen(port, "127.0.0.1", () => { |
| 269 | server.close((error) => resolve(error ? false : port)); | |
| 269 | server.close((error) => resolve(!error)); | |
| 270 | 270 | }); |
| 271 | 271 | }); |
| 272 | 272 | } |
| 273 | 273 | |
| 274 | // macOS lets the bind above succeed while another process listens on | |
| 275 | // *:<port>, so also skip any port that already answers on loopback. | |
| 276 | function answersOnLoopback(port) { | |
| 277 | return new Promise((resolve) => { | |
| 278 | const socket = net.connect({ port, host: "127.0.0.1" }); | |
| 279 | socket.setTimeout(1000); | |
| 280 | socket.once("connect", () => { | |
| 281 | socket.destroy(); | |
| 282 | resolve(true); | |
| 283 | }); | |
| 284 | socket.once("timeout", () => { | |
| 285 | socket.destroy(); | |
| 286 | resolve(true); | |
| 287 | }); | |
| 288 | socket.once("error", () => resolve(false)); | |
| 289 | }); | |
| 290 | } | |
| 291 | ||
| 274 | 292 | (async () => { |
| 275 | 293 | for (let port = start; port < end; port += 1) { |
| 276 | const available = await tryPort(port); | |
| 294 | const available = (await canBind(port)) && !(await answersOnLoopback(port)); | |
| 277 | 295 | if (available) { |
| 278 | 296 | process.stdout.write(String(port)); |
| 279 | 297 | return; |