| 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,14 +18,31 @@ import { Argument, Command } from "effect/cli"; |
| 18 | * them. | 18 | * them. |
| 19 | */ | 19 | */ |
| 20 | 20 | ||
| 21 | const tryPort = (port: number) => | 21 | const canBind = (port: number) => |
| 22 | new Promise<number | false>((resolve) => { | 22 | new Promise<boolean>((resolve) => { |
| 23 | const server = NodeNet.createServer(); | 23 | const server = NodeNet.createServer(); |
| 24 | server.unref(); | 24 | server.unref(); |
| 25 | server.once("error", () => resolve(false)); | 25 | server.once("error", () => resolve(false)); |
| 26 | server.listen(port, "127.0.0.1", () => { | 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 | /** Prints the first free loopback port from the preferred one, scanning `window` ports. */ | 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,7 +57,7 @@ const pickPort = Command.make("pick-port", { |
| 40 | const preferred = Number.parseInt(raw, 10); | 57 | const preferred = Number.parseInt(raw, 10); |
| 41 | const start = Number.isInteger(preferred) ? preferred : defaultPort; | 58 | const start = Number.isInteger(preferred) ? preferred : defaultPort; |
| 42 | for (let port = start; port < start + scanWindow; port += 1) { | 59 | for (let port = start; port < start + scanWindow; port += 1) { |
| 43 | if (await tryPort(port)) { | 60 | if ((await canBind(port)) && !(await answersOnLoopback(port))) { |
| 44 | process.stdout.write(String(port)); | 61 | process.stdout.write(String(port)); |
| 45 | return; | 62 | return; |
| 46 | } | 63 | } |
packages/ssh/src/tunnel.test.ts+74| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import * as NodeNet from "node:net"; | ||
| 2 | |||
| 1 | import { assert, describe, it } from "@effect/vitest"; | 3 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as NetService from "@t3tools/shared/Net"; | 5 | import * as NetService from "@t3tools/shared/Net"; |
| ... | @@ -693,6 +695,78 @@ describe("ssh tunnel scripts", () => { | ... | @@ -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 | // The archive runner is generated shell; string assertions cannot prove the | 770 | // The archive runner is generated shell; string assertions cannot prove the |
| 697 | // lock excludes concurrent installers. Run the real script against a tiny | 771 | // lock excludes concurrent installers. Run the real script against a tiny |
| 698 | // fake archive served from a file:// mirror. | 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,20 +260,38 @@ const preferred = Number.parseInt(raw, 10); |
| 260 | const start = Number.isInteger(preferred) ? preferred : defaultPort; | 260 | const start = Number.isInteger(preferred) ? preferred : defaultPort; |
| 261 | const end = start + scanWindow; | 261 | const end = start + scanWindow; |
| 262 | 262 | ||
| 263 | function tryPort(port) { | 263 | function canBind(port) { |
| 264 | return new Promise((resolve) => { | 264 | return new Promise((resolve) => { |
| 265 | const server = net.createServer(); | 265 | const server = net.createServer(); |
| 266 | server.unref(); | 266 | server.unref(); |
| 267 | server.once("error", () => resolve(false)); | 267 | server.once("error", () => resolve(false)); |
| 268 | server.listen(port, "127.0.0.1", () => { | 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 | (async () => { | 292 | (async () => { |
| 275 | for (let port = start; port < end; port += 1) { | 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 | if (available) { | 295 | if (available) { |
| 278 | process.stdout.write(String(port)); | 296 | process.stdout.write(String(port)); |
| 279 | return; | 297 | return; |