authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 04:48:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 04:48:56-07:00
logb037b9a535ff30f3f390da682dabeffe4c439044
treeb9a7c2e01879b7fcdc06ec7e006b647d1e4d122d
parent0796638512d4c610469905adc597cc33c307fc32
signature Signed by PGP key B5690EEEBB952194

fix(server): open in editor works over Tailscale SSH without a local sshd (#19402)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 185 insertions(+), 78 deletions(-)

apps/server/src/environment/RemoteOpenTargets.test.ts+54-12
...@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";...@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Sink from "effect/Sink";6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";7import * as Stream from "effect/Stream";
8import * as ChildProcess from "effect/process/ChildProcess";
8import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";9import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
9import { describe, expect } from "vite-plus/test";10import { describe, expect } from "vite-plus/test";
1011
...@@ -16,27 +17,34 @@ const TAILSCALE_STATUS_JSON = JSON.stringify({...@@ -16,27 +17,34 @@ const TAILSCALE_STATUS_JSON = JSON.stringify({
16 Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] },17 Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] },
17});18});
1819
19/** Spawner whose `tailscale status --json` exits with the given output. */20interface CommandResult {
20const layerSpawner = (input: { readonly exitCode: number; readonly stdout: string }) =>21 readonly exitCode: number;
22 readonly stdout: string;
23}
24
25/** Spawner answering `tailscale status --json` and `tailscale debug prefs`. */
26const layerSpawner = (input: { readonly status: CommandResult; readonly prefs: CommandResult }) =>
21 Layer.succeed(27 Layer.succeed(
22 ChildProcessSpawner.ChildProcessSpawner,28 ChildProcessSpawner.ChildProcessSpawner,
23 ChildProcessSpawner.make(() =>29 ChildProcessSpawner.make((command) => {
24 Effect.succeed(30 const args = ChildProcess.isStandardCommand(command) ? command.args : [];
31 const result = args[0] === "debug" ? input.prefs : input.status;
32 return Effect.succeed(
25 ChildProcessSpawner.makeHandle({33 ChildProcessSpawner.makeHandle({
26 pid: ChildProcessSpawner.ProcessId(1),34 pid: ChildProcessSpawner.ProcessId(1),
27 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)),35 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)),
28 isRunning: Effect.succeed(false),36 isRunning: Effect.succeed(false),
29 kill: () => Effect.void,37 kill: () => Effect.void,
30 unref: Effect.succeed(Effect.void),38 unref: Effect.succeed(Effect.void),
31 stdin: Sink.drain,39 stdin: Sink.drain,
32 stdout: Stream.make(encoder.encode(input.stdout)),40 stdout: Stream.make(encoder.encode(result.stdout)),
33 stderr: Stream.empty,41 stderr: Stream.empty,
34 all: Stream.empty,42 all: Stream.empty,
35 getInputFd: () => Sink.drain,43 getInputFd: () => Sink.drain,
36 getOutputFd: () => Stream.empty,44 getOutputFd: () => Stream.empty,
37 }),45 }),
38 ),46 );
39 ),47 }),
40 );48 );
4149
42const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) =>50const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) =>
...@@ -50,7 +58,7 @@ const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) =>...@@ -50,7 +58,7 @@ const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) =>
5058
51const resolveTargets = (input: {59const resolveTargets = (input: {
52 readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean };60 readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean };
53 readonly tailscale: { readonly exitCode: number; readonly stdout: string };61 readonly tailscale: { readonly status: CommandResult; readonly prefs: CommandResult };
54 readonly hostname: string;62 readonly hostname: string;
55}) =>63}) =>
56 Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe(64 Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe(
...@@ -62,11 +70,23 @@ const resolveTargets = (input: {...@@ -62,11 +70,23 @@ const resolveTargets = (input: {
62 ),70 ),
63 );71 );
6472
65const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON };73// `tailscale debug prefs` output, trimmed. `tailscale up --ssh` sets RunSSH.
66const TAILSCALE_DOWN = { exitCode: 1, stdout: "" };74const prefsJson = (prefs: { readonly RunSSH: boolean; readonly WantRunning: boolean }) => ({
75 exitCode: 0,
76 stdout: JSON.stringify(
77 { ControlURL: "https://controlplane.tailscale.com", ...prefs },
78 null,
79 "\t",
80 ),
81});
82
83const STATUS_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON };
84const TAILSCALE_UP = { status: STATUS_UP, prefs: prefsJson({ RunSSH: false, WantRunning: true }) };
85const TAILSCALE_SSH = { status: STATUS_UP, prefs: prefsJson({ RunSSH: true, WantRunning: true }) };
86const TAILSCALE_DOWN = { status: { exitCode: 1, stdout: "" }, prefs: { exitCode: 1, stdout: "" } };
6787
68describe("RemoteOpenTargets", () => {88describe("RemoteOpenTargets", () => {
69 it.effect("advertises nothing when no sshd accepts on either loopback", () =>89 it.effect("advertises nothing when no sshd accepts and Tailscale SSH is off", () =>
70 Effect.gen(function* () {90 Effect.gen(function* () {
71 const targets = yield* resolveTargets({91 const targets = yield* resolveTargets({
72 sshd: { ipv4: false, ipv6: false },92 sshd: { ipv4: false, ipv6: false },
...@@ -77,6 +97,28 @@ describe("RemoteOpenTargets", () => {...@@ -77,6 +97,28 @@ describe("RemoteOpenTargets", () => {
77 }),97 }),
78 );98 );
7999
100 it.effect("advertises only the tailnet name when Tailscale SSH serves without sshd", () =>
101 Effect.gen(function* () {
102 const targets = yield* resolveTargets({
103 sshd: { ipv4: false, ipv6: false },
104 tailscale: TAILSCALE_SSH,
105 hostname: "bb-1",
106 });
107 expect(targets).toEqual([{ kind: "tailscale", host: "bb-1.tail1234.ts.net" }]);
108 }),
109 );
110
111 it.effect("ignores a stored RunSSH after `tailscale down`", () =>
112 Effect.gen(function* () {
113 const targets = yield* resolveTargets({
114 sshd: { ipv4: false, ipv6: false },
115 tailscale: { status: STATUS_UP, prefs: prefsJson({ RunSSH: true, WantRunning: false }) },
116 hostname: "bb-1",
117 });
118 expect(targets).toEqual([]);
119 }),
120 );
121
80 it.effect("orders the tailnet name before the mDNS name", () =>122 it.effect("orders the tailnet name before the mDNS name", () =>
81 Effect.gen(function* () {123 Effect.gen(function* () {
82 const targets = yield* resolveTargets({124 const targets = yield* resolveTargets({
apps/server/src/environment/RemoteOpenTargets.ts+24-10
...@@ -3,15 +3,16 @@...@@ -3,15 +3,16 @@
3 * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`).3 * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`).
4 *4 *
5 * The server can only check itself: sshd listening locally, tailscaled5 * The server can only check itself: sshd listening locally, tailscaled
6 * reporting a MagicDNS name, and the machine hostname for mDNS. Whether a6 * reporting a MagicDNS name and whether Tailscale SSH is on, and the machine
7 * given name resolves from the viewer's machine is inherently client-side.7 * hostname for mDNS. Whether a given name resolves from the viewer's machine
8 * is inherently client-side.
8 * Targets are ordered most-reachable first (tailnet name works from anywhere9 * Targets are ordered most-reachable first (tailnet name works from anywhere
9 * on the tailnet; `<hostname>.local` only on the same LAN).10 * on the tailnet; `<hostname>.local` only on the same LAN).
10 */11 */
11import { type RemoteOpenTarget } from "@t3tools/contracts";12import { type RemoteOpenTarget } from "@t3tools/contracts";
12import * as HostProcess from "@t3tools/shared/HostProcess";13import * as HostProcess from "@t3tools/shared/HostProcess";
13import * as NetService from "@t3tools/shared/Net";14import * as NetService from "@t3tools/shared/Net";
14import { readTailscaleStatus } from "@t3tools/tailscale";15import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale";
15import * as Context from "effect/Context";16import * as Context from "effect/Context";
16import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";18import * as Layer from "effect/Layer";
...@@ -32,19 +33,12 @@ export const make = Effect.gen(function* () {...@@ -32,19 +33,12 @@ export const make = Effect.gen(function* () {
32 const net = yield* NetService.NetService;33 const net = yield* NetService.NetService;
3334
34 const resolveTargets = Effect.gen(function* () {35 const resolveTargets = Effect.gen(function* () {
35 // No local sshd means no name can work; advertise nothing so clients
36 // render a clear "no SSH route" state instead of links that hang.
37 // Check both loopback families: sshd can be bound IPv6-only.36 // Check both loopback families: sshd can be bound IPv6-only.
38 const sshdListening = yield* Effect.zipWith(37 const sshdListening = yield* Effect.zipWith(
39 net.hasListenerOnHost(SSH_PORT, "127.0.0.1"),38 net.hasListenerOnHost(SSH_PORT, "127.0.0.1"),
40 net.hasListenerOnHost(SSH_PORT, "::1"),39 net.hasListenerOnHost(SSH_PORT, "::1"),
41 (ipv4, ipv6) => ipv4 || ipv6,40 (ipv4, ipv6) => ipv4 || ipv6,
42 );41 );
43 if (!sshdListening) {
44 return [];
45 }
46
47 const targets: Array<RemoteOpenTarget> = [];
4842
49 // Tailscale absent or down is the common case, not an error.43 // Tailscale absent or down is the common case, not an error.
50 const magicDnsName = yield* readTailscaleStatus.pipe(44 const magicDnsName = yield* readTailscaleStatus.pipe(
...@@ -52,6 +46,26 @@ export const make = Effect.gen(function* () {...@@ -52,6 +46,26 @@ export const make = Effect.gen(function* () {
52 Effect.orElseSucceed(() => null),46 Effect.orElseSucceed(() => null),
53 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),47 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
54 );48 );
49
50 const targets: Array<RemoteOpenTarget> = [];
51
52 if (!sshdListening) {
53 // Tailscale SSH answers on the tailnet name with no local sshd, so only
54 // that name can work. A running tailscaled alone is not an SSH route.
55 // With neither, advertise nothing so clients render a clear "no SSH
56 // route" state instead of links that hang.
57 if (
58 magicDnsName !== null &&
59 (yield* readTailscaleSshEnabled.pipe(
60 Effect.orElseSucceed(() => false),
61 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
62 ))
63 ) {
64 targets.push({ kind: "tailscale", host: magicDnsName });
65 }
66 return targets;
67 }
68
55 if (magicDnsName !== null) {69 if (magicDnsName !== null) {
56 targets.push({ kind: "tailscale", host: magicDnsName });70 targets.push({ kind: "tailscale", host: magicDnsName });
57 }71 }
packages/tailscale/src/tailscale.test.ts+22
...@@ -16,7 +16,9 @@ import {...@@ -16,7 +16,9 @@ import {
16 ensureTailscaleServe,16 ensureTailscaleServe,
17 isTailscaleIpv4Address,17 isTailscaleIpv4Address,
18 parseTailscaleMagicDnsName,18 parseTailscaleMagicDnsName,
19 parseTailscaleSshEnabled,
19 parseTailscaleStatus,20 parseTailscaleStatus,
21 readTailscaleSshEnabled,
20 readTailscaleStatus,22 readTailscaleStatus,
21 TAILSCALE_STATUS_TIMEOUT,23 TAILSCALE_STATUS_TIMEOUT,
22 TailscaleCommandExitError,24 TailscaleCommandExitError,
...@@ -164,6 +166,26 @@ describe("tailscale", () => {...@@ -164,6 +166,26 @@ describe("tailscale", () => {
164 }),166 }),
165 );167 );
166168
169 it.effect("reads Tailscale SSH enablement from local prefs", () => {
170 // `tailscale debug prefs` output, trimmed.
171 const prefsJson =
172 '{\n\t"ControlURL": "https://controlplane.tailscale.com",\n\t"RunSSH": true,\n\t"WantRunning": true\n}\n';
173 const layer = layerMockSpawner((command, args) => {
174 assert.equal(command, "tailscale");
175 assert.deepEqual(args, ["debug", "prefs"]);
176 return { stdout: prefsJson };
177 });
178
179 return Effect.gen(function* () {
180 assert.isTrue(yield* readTailscaleSshEnabled.pipe(Effect.provide(layer)));
181 assert.isFalse(yield* parseTailscaleSshEnabled('{"RunSSH":false,"WantRunning":true}'));
182 assert.isFalse(yield* parseTailscaleSshEnabled('{"RunSSH":true,"WantRunning":false}'));
183 assert.isFalse(yield* parseTailscaleSshEnabled("{}"));
184 const error = yield* parseTailscaleSshEnabled("{not-json").pipe(Effect.flip);
185 assert.instanceOf(error, TailscaleStatusParseError);
186 });
187 });
188
167 it.effect("builds clean HTTPS base URLs", () =>189 it.effect("builds clean HTTPS base URLs", () =>
168 Effect.sync(() => {190 Effect.sync(() => {
169 assert.equal(191 assert.equal(
packages/tailscale/src/tailscale.ts+85-56
...@@ -19,7 +19,7 @@ const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "...@@ -19,7 +19,7 @@ const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "
1919
20const TailscaleCommandContext = {20const TailscaleCommandContext = {
21 executable: Schema.Literals(["tailscale", "tailscale.exe"]),21 executable: Schema.Literals(["tailscale", "tailscale.exe"]),
22 subcommand: Schema.Literals(["status", "serve"]),22 subcommand: Schema.Literals(["status", "serve", "debug"]),
23 argumentCount: Schema.Number,23 argumentCount: Schema.Number,
24};24};
2525
...@@ -214,64 +214,93 @@ export const parseTailscaleStatus = (...@@ -214,64 +214,93 @@ export const parseTailscaleStatus = (
214 }),214 }),
215 );215 );
216216
217export const readTailscaleStatus = Effect.gen(function* () {217/** Runs a read-only tailscale command and returns its stdout. */
218 const args = ["status", "--json"];218const readTailscaleOutput = (subcommand: "status" | "debug", args: readonly string[]) =>
219 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;219 Effect.gen(function* () {
220 const hostPlatform = yield* HostProcess.Platform;220 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
221 const executable = tailscaleCommandForPlatform(hostPlatform);221 const hostPlatform = yield* HostProcess.Platform;
222 const commandContext = {222 const executable = tailscaleCommandForPlatform(hostPlatform);
223 executable,223 const commandContext = {
224 subcommand: "status" as const,224 executable,
225 argumentCount: args.length,225 subcommand,
226 };226 argumentCount: args.length,
227 return yield* Effect.gen(function* () {227 };
228 const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe(228 return yield* Effect.gen(function* () {
229 Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),229 const child = yield* spawner.spawn(ChildProcess.make(executable, [...args])).pipe(
230 // Spawning can also fail as a defect rather than a typed error - a230 Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
231 // non-directory entry on PATH makes node throw ENOTDIR synchronously.231 // Spawning can also fail as a defect rather than a typed error - a
232 // `mapError` never sees that, so it would escape as an uncaught error.232 // non-directory entry on PATH makes node throw ENOTDIR synchronously.
233 Effect.catchDefect((cause) =>233 // `mapError` never sees that, so it would escape as an uncaught error.
234 Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })),234 Effect.catchDefect((cause) =>
235 ),235 Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })),
236 );
237 const [stdout, stderr, exitCode] = yield* Effect.all(
238 [
239 collectStdout(child.stdout),
240 collectStderr(child.stderr),
241 child.exitCode.pipe(Effect.map(Number)),
242 ],
243 { concurrency: "unbounded" },
244 ).pipe(
245 Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })),
246 );
247 if (exitCode !== 0) {
248 return yield* new TailscaleCommandExitError({
249 ...commandContext,
250 exitCode,
251 stdoutLength: stdout.length,
252 stderrLength: stderr.length,
253 ...(stderrDiagnosticOf(stderr) !== undefined
254 ? { stderrDiagnostic: stderrDiagnosticOf(stderr) }
255 : {}),
256 });
257 }
258 return yield* parseTailscaleStatus(stdout);
259 }).pipe(
260 Effect.scoped,
261 Effect.timeout(TAILSCALE_STATUS_TIMEOUT),
262 Effect.catchTags({
263 TimeoutError: (cause) =>
264 Effect.fail(
265 new TailscaleCommandTimeoutError({
266 ...commandContext,
267 timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT),
268 cause,
269 }),
270 ),236 ),
271 }),237 );
272 );238 const [stdout, stderr, exitCode] = yield* Effect.all(
239 [
240 collectStdout(child.stdout),
241 collectStderr(child.stderr),
242 child.exitCode.pipe(Effect.map(Number)),
243 ],
244 { concurrency: "unbounded" },
245 ).pipe(
246 Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })),
247 );
248 if (exitCode !== 0) {
249 return yield* new TailscaleCommandExitError({
250 ...commandContext,
251 exitCode,
252 stdoutLength: stdout.length,
253 stderrLength: stderr.length,
254 ...(stderrDiagnosticOf(stderr) !== undefined
255 ? { stderrDiagnostic: stderrDiagnosticOf(stderr) }
256 : {}),
257 });
258 }
259 return stdout;
260 }).pipe(
261 Effect.scoped,
262 Effect.timeout(TAILSCALE_STATUS_TIMEOUT),
263 Effect.catchTags({
264 TimeoutError: (cause) =>
265 Effect.fail(
266 new TailscaleCommandTimeoutError({
267 ...commandContext,
268 timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT),
269 cause,
270 }),
271 ),
272 }),
273 );
274 });
275
276export const readTailscaleStatus = readTailscaleOutput("status", ["status", "--json"]).pipe(
277 Effect.flatMap(parseTailscaleStatus),
278);
279
280const TailscalePrefsJson = Schema.Struct({
281 RunSSH: Schema.optional(Schema.Unknown),
282 WantRunning: Schema.optional(Schema.Unknown),
273});283});
274284
285const decodeTailscalePrefsJson = Schema.decodeEffect(Schema.fromJsonString(TailscalePrefsJson));
286
287/**
288 * Whether this node serves Tailscale SSH (`tailscale up --ssh`), read from its
289 * prefs. `tailscale status --json` does not report this for the local node.
290 * `tailscale down` clears WantRunning but keeps RunSSH, so check both.
291 */
292export const parseTailscaleSshEnabled = (
293 rawPrefsJson: string,
294): Effect.Effect<boolean, TailscaleStatusParseError> =>
295 decodeTailscalePrefsJson(rawPrefsJson).pipe(
296 Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),
297 Effect.map((prefs) => prefs.RunSSH === true && prefs.WantRunning !== false),
298 );
299
300export const readTailscaleSshEnabled = readTailscaleOutput("debug", ["debug", "prefs"]).pipe(
301 Effect.flatMap(parseTailscaleSshEnabled),
302);
303
275export function buildTailscaleHttpsBaseUrl(input: {304export function buildTailscaleHttpsBaseUrl(input: {
276 readonly magicDnsName: string;305 readonly magicDnsName: string;
277 readonly servePort?: number;306 readonly servePort?: number;