From b037b9a535ff30f3f390da682dabeffe4c439044 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 04:48:56 -0700 Subject: [PATCH] fix(server): open in editor works over Tailscale SSH without a local sshd (#19402) Co-authored-by: Claude Opus 5.5 --- .../src/environment/RemoteOpenTargets.test.ts | 66 ++++++-- .../src/environment/RemoteOpenTargets.ts | 34 +++-- packages/tailscale/src/tailscale.test.ts | 22 +++ packages/tailscale/src/tailscale.ts | 141 +++++++++++------- 4 files changed, 185 insertions(+), 78 deletions(-) diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index 8a90a9345df30ad0645ed7d2b210d0251503c762..fb3c1841bfe574ba2d6fe86de73fc17025bc52b4 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { describe, expect } from "vite-plus/test"; @@ -16,27 +17,34 @@ const TAILSCALE_STATUS_JSON = JSON.stringify({ Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, }); -/** Spawner whose `tailscale status --json` exits with the given output. */ -const layerSpawner = (input: { readonly exitCode: number; readonly stdout: string }) => +interface CommandResult { + readonly exitCode: number; + readonly stdout: string; +} + +/** Spawner answering `tailscale status --json` and `tailscale debug prefs`. */ +const layerSpawner = (input: { readonly status: CommandResult; readonly prefs: CommandResult }) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( + ChildProcessSpawner.make((command) => { + const args = ChildProcess.isStandardCommand(command) ? command.args : []; + const result = args[0] === "debug" ? input.prefs : input.status; + return Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.make(encoder.encode(input.stdout)), + stdout: Stream.make(encoder.encode(result.stdout)), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }), - ), - ), + ); + }), ); const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => @@ -50,7 +58,7 @@ const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => const resolveTargets = (input: { readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean }; - readonly tailscale: { readonly exitCode: number; readonly stdout: string }; + readonly tailscale: { readonly status: CommandResult; readonly prefs: CommandResult }; readonly hostname: string; }) => Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe( @@ -62,11 +70,23 @@ const resolveTargets = (input: { ), ); -const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON }; -const TAILSCALE_DOWN = { exitCode: 1, stdout: "" }; +// `tailscale debug prefs` output, trimmed. `tailscale up --ssh` sets RunSSH. +const prefsJson = (prefs: { readonly RunSSH: boolean; readonly WantRunning: boolean }) => ({ + exitCode: 0, + stdout: JSON.stringify( + { ControlURL: "https://controlplane.tailscale.com", ...prefs }, + null, + "\t", + ), +}); + +const STATUS_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON }; +const TAILSCALE_UP = { status: STATUS_UP, prefs: prefsJson({ RunSSH: false, WantRunning: true }) }; +const TAILSCALE_SSH = { status: STATUS_UP, prefs: prefsJson({ RunSSH: true, WantRunning: true }) }; +const TAILSCALE_DOWN = { status: { exitCode: 1, stdout: "" }, prefs: { exitCode: 1, stdout: "" } }; describe("RemoteOpenTargets", () => { - it.effect("advertises nothing when no sshd accepts on either loopback", () => + it.effect("advertises nothing when no sshd accepts and Tailscale SSH is off", () => Effect.gen(function* () { const targets = yield* resolveTargets({ sshd: { ipv4: false, ipv6: false }, @@ -77,6 +97,28 @@ describe("RemoteOpenTargets", () => { }), ); + it.effect("advertises only the tailnet name when Tailscale SSH serves without sshd", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: false, ipv6: false }, + tailscale: TAILSCALE_SSH, + hostname: "bb-1", + }); + expect(targets).toEqual([{ kind: "tailscale", host: "bb-1.tail1234.ts.net" }]); + }), + ); + + it.effect("ignores a stored RunSSH after `tailscale down`", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: false, ipv6: false }, + tailscale: { status: STATUS_UP, prefs: prefsJson({ RunSSH: true, WantRunning: false }) }, + hostname: "bb-1", + }); + expect(targets).toEqual([]); + }), + ); + it.effect("orders the tailnet name before the mDNS name", () => Effect.gen(function* () { const targets = yield* resolveTargets({ diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index 1cd5751bc2d4e5472a0597cdd9646db241daf187..ca41e85f168833fbb8f9aa5bedc366f2f092899b 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -3,15 +3,16 @@ * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`). * * The server can only check itself: sshd listening locally, tailscaled - * reporting a MagicDNS name, and the machine hostname for mDNS. Whether a - * given name resolves from the viewer's machine is inherently client-side. + * reporting a MagicDNS name and whether Tailscale SSH is on, and the machine + * hostname for mDNS. Whether a given name resolves from the viewer's machine + * is inherently client-side. * Targets are ordered most-reachable first (tailnet name works from anywhere * on the tailnet; `.local` only on the same LAN). */ import { type RemoteOpenTarget } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; -import { readTailscaleStatus } from "@t3tools/tailscale"; +import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -32,19 +33,12 @@ export const make = Effect.gen(function* () { const net = yield* NetService.NetService; const resolveTargets = Effect.gen(function* () { - // No local sshd means no name can work; advertise nothing so clients - // render a clear "no SSH route" state instead of links that hang. // Check both loopback families: sshd can be bound IPv6-only. const sshdListening = yield* Effect.zipWith( net.hasListenerOnHost(SSH_PORT, "127.0.0.1"), net.hasListenerOnHost(SSH_PORT, "::1"), (ipv4, ipv6) => ipv4 || ipv6, ); - if (!sshdListening) { - return []; - } - - const targets: Array = []; // Tailscale absent or down is the common case, not an error. const magicDnsName = yield* readTailscaleStatus.pipe( @@ -52,6 +46,26 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => null), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); + + const targets: Array = []; + + if (!sshdListening) { + // Tailscale SSH answers on the tailnet name with no local sshd, so only + // that name can work. A running tailscaled alone is not an SSH route. + // With neither, advertise nothing so clients render a clear "no SSH + // route" state instead of links that hang. + if ( + magicDnsName !== null && + (yield* readTailscaleSshEnabled.pipe( + Effect.orElseSucceed(() => false), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + )) + ) { + targets.push({ kind: "tailscale", host: magicDnsName }); + } + return targets; + } + if (magicDnsName !== null) { targets.push({ kind: "tailscale", host: magicDnsName }); } diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index c800bf72defced088d49f451642f64a32a6247b2..2936f2004b7d2849ec83931d1330042498972acf 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -16,7 +16,9 @@ import { ensureTailscaleServe, isTailscaleIpv4Address, parseTailscaleMagicDnsName, + parseTailscaleSshEnabled, parseTailscaleStatus, + readTailscaleSshEnabled, readTailscaleStatus, TAILSCALE_STATUS_TIMEOUT, TailscaleCommandExitError, @@ -164,6 +166,26 @@ describe("tailscale", () => { }), ); + it.effect("reads Tailscale SSH enablement from local prefs", () => { + // `tailscale debug prefs` output, trimmed. + const prefsJson = + '{\n\t"ControlURL": "https://controlplane.tailscale.com",\n\t"RunSSH": true,\n\t"WantRunning": true\n}\n'; + const layer = layerMockSpawner((command, args) => { + assert.equal(command, "tailscale"); + assert.deepEqual(args, ["debug", "prefs"]); + return { stdout: prefsJson }; + }); + + return Effect.gen(function* () { + assert.isTrue(yield* readTailscaleSshEnabled.pipe(Effect.provide(layer))); + assert.isFalse(yield* parseTailscaleSshEnabled('{"RunSSH":false,"WantRunning":true}')); + assert.isFalse(yield* parseTailscaleSshEnabled('{"RunSSH":true,"WantRunning":false}')); + assert.isFalse(yield* parseTailscaleSshEnabled("{}")); + const error = yield* parseTailscaleSshEnabled("{not-json").pipe(Effect.flip); + assert.instanceOf(error, TailscaleStatusParseError); + }); + }); + it.effect("builds clean HTTPS base URLs", () => Effect.sync(() => { assert.equal( diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index e3579891b4c6e4155700a67b847a45ac2ae17f34..14f7757f406e82f7d2ae108ddeac8a4cbe8c16f6 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -19,7 +19,7 @@ const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | " const TailscaleCommandContext = { executable: Schema.Literals(["tailscale", "tailscale.exe"]), - subcommand: Schema.Literals(["status", "serve"]), + subcommand: Schema.Literals(["status", "serve", "debug"]), argumentCount: Schema.Number, }; @@ -214,64 +214,93 @@ export const parseTailscaleStatus = ( }), ); -export const readTailscaleStatus = Effect.gen(function* () { - const args = ["status", "--json"]; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcess.Platform; - const executable = tailscaleCommandForPlatform(hostPlatform); - const commandContext = { - executable, - subcommand: "status" as const, - argumentCount: args.length, - }; - return yield* Effect.gen(function* () { - const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( - Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), - // Spawning can also fail as a defect rather than a typed error - a - // non-directory entry on PATH makes node throw ENOTDIR synchronously. - // `mapError` never sees that, so it would escape as an uncaught error. - Effect.catchDefect((cause) => - Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), - ), - ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStdout(child.stdout), - collectStderr(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), - ); - if (exitCode !== 0) { - return yield* new TailscaleCommandExitError({ - ...commandContext, - exitCode, - stdoutLength: stdout.length, - stderrLength: stderr.length, - ...(stderrDiagnosticOf(stderr) !== undefined - ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } - : {}), - }); - } - return yield* parseTailscaleStatus(stdout); - }).pipe( - Effect.scoped, - Effect.timeout(TAILSCALE_STATUS_TIMEOUT), - Effect.catchTags({ - TimeoutError: (cause) => - Effect.fail( - new TailscaleCommandTimeoutError({ - ...commandContext, - timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), - cause, - }), +/** Runs a read-only tailscale command and returns its stdout. */ +const readTailscaleOutput = (subcommand: "status" | "debug", args: readonly string[]) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcess.Platform; + const executable = tailscaleCommandForPlatform(hostPlatform); + const commandContext = { + executable, + subcommand, + argumentCount: args.length, + }; + return yield* Effect.gen(function* () { + const child = yield* spawner.spawn(ChildProcess.make(executable, [...args])).pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + // Spawning can also fail as a defect rather than a typed error - a + // non-directory entry on PATH makes node throw ENOTDIR synchronously. + // `mapError` never sees that, so it would escape as an uncaught error. + Effect.catchDefect((cause) => + Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), ), - }), - ); + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStdout(child.stdout), + collectStderr(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), + ); + if (exitCode !== 0) { + return yield* new TailscaleCommandExitError({ + ...commandContext, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + ...(stderrDiagnosticOf(stderr) !== undefined + ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } + : {}), + }); + } + return stdout; + }).pipe( + Effect.scoped, + Effect.timeout(TAILSCALE_STATUS_TIMEOUT), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail( + new TailscaleCommandTimeoutError({ + ...commandContext, + timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), + cause, + }), + ), + }), + ); + }); + +export const readTailscaleStatus = readTailscaleOutput("status", ["status", "--json"]).pipe( + Effect.flatMap(parseTailscaleStatus), +); + +const TailscalePrefsJson = Schema.Struct({ + RunSSH: Schema.optional(Schema.Unknown), + WantRunning: Schema.optional(Schema.Unknown), }); +const decodeTailscalePrefsJson = Schema.decodeEffect(Schema.fromJsonString(TailscalePrefsJson)); + +/** + * Whether this node serves Tailscale SSH (`tailscale up --ssh`), read from its + * prefs. `tailscale status --json` does not report this for the local node. + * `tailscale down` clears WantRunning but keeps RunSSH, so check both. + */ +export const parseTailscaleSshEnabled = ( + rawPrefsJson: string, +): Effect.Effect => + decodeTailscalePrefsJson(rawPrefsJson).pipe( + Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), + Effect.map((prefs) => prefs.RunSSH === true && prefs.WantRunning !== false), + ); + +export const readTailscaleSshEnabled = readTailscaleOutput("debug", ["debug", "prefs"]).pipe( + Effect.flatMap(parseTailscaleSshEnabled), +); + export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; readonly servePort?: number; -- 2.54.0