From 23f56dc6902278068bd3480e0375da10a534500e Mon Sep 17 00:00:00 2001 From: Killian Cazin Date: Fri, 9 Oct 2026 17:07:59 +0900 Subject: [PATCH] fix(server): SSH device-host tunnels no longer inherit ControlMaster The device-host tunnel inherited ControlMaster/ControlPersist from the user's ssh config, so it became a mux client that handed its forwards to the control master and exited. The reconnect loop then leaked two listening ports into the master every ~1.5 s until the ephemeral range ran out. Opt the tunnel out of multiplexing, as the environment tunnel already does (#4347). Fixes #17437 Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/device/SshDeviceHost.test.ts | 16 ++++++++++++++++ apps/server/src/device/SshDeviceHost.ts | 8 ++++++++ 2 files changed, 24 insertions(+) diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index 871f66bae0d6..3cffc11adb54 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -20,6 +20,8 @@ it.effect("preserves installed status after probes and cleans failed agent activ const home = yield* fs.makeTempDirectoryScoped(); const modes: string[] = []; const owners: string[] = []; + const tunnelArgs: string[][] = []; + const commandArgs: string[][] = []; let forwards = 0; let failForward = true; let rejectConfig = true; @@ -27,6 +29,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ Effect.gen(function* () { if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); const forwarding = command.args.includes("-N"); + (forwarding ? tunnelArgs : commandArgs).push([...command.args]); let output = ""; if (forwarding) { if (failForward) { @@ -132,6 +135,19 @@ it.effect("preserves installed status after probes and cleans failed agent activ expect((yield* host.summary).agentDeviceInstalled).toBe(true); yield* host.stopAgent; expect(forwards).toBe(1); + const multiplexingOff = ["ControlMaster=no", "ControlPath=none", "ControlPersist=no"]; + expect(tunnelArgs.length).toBeGreaterThan(0); + for (const args of tunnelArgs) { + for (const option of multiplexingOff) { + const index = args.indexOf(option); + expect(index).toBeGreaterThan(0); + expect(args[index - 1]).toBe("-o"); + } + } + expect(commandArgs.length).toBeGreaterThan(0); + for (const args of commandArgs) { + for (const option of multiplexingOff) expect(args).not.toContain(option); + } yield* host.stop; expect(forwards).toBe(0); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index e1ac55edc3f0..76afd9488df7 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -242,6 +242,14 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( ...identityArgs(config), "-o", "ExitOnForwardFailure=yes", + // A multiplexed client hands its -L forwards to the control master and exits, + // so the tunnel must own its own connection. + "-o", + "ControlMaster=no", + "-o", + "ControlPath=none", + "-o", + "ControlPersist=no", "-o", "ServerAliveInterval=10", "-o",