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";
55import * as Layer from "effect/Layer";
66import * as Sink from "effect/Sink";
77import * as Stream from "effect/Stream";
8import * as ChildProcess from "effect/process/ChildProcess";
89import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
910import { describe, expect } from "vite-plus/test";
1011
......@@ -16,27 +17,34 @@ const TAILSCALE_STATUS_JSON = JSON.stringify({
1617 Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] },
1718});
1819
19/** Spawner whose `tailscale status --json` exits with the given output. */
20const layerSpawner = (input: { readonly exitCode: number; readonly stdout: string }) =>
20interface CommandResult {
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 }) =>
2127 Layer.succeed(
2228 ChildProcessSpawner.ChildProcessSpawner,
23 ChildProcessSpawner.make(() =>
24 Effect.succeed(
29 ChildProcessSpawner.make((command) => {
30 const args = ChildProcess.isStandardCommand(command) ? command.args : [];
31 const result = args[0] === "debug" ? input.prefs : input.status;
32 return Effect.succeed(
2533 ChildProcessSpawner.makeHandle({
2634 pid: ChildProcessSpawner.ProcessId(1),
27 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)),
35 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)),
2836 isRunning: Effect.succeed(false),
2937 kill: () => Effect.void,
3038 unref: Effect.succeed(Effect.void),
3139 stdin: Sink.drain,
32 stdout: Stream.make(encoder.encode(input.stdout)),
40 stdout: Stream.make(encoder.encode(result.stdout)),
3341 stderr: Stream.empty,
3442 all: Stream.empty,
3543 getInputFd: () => Sink.drain,
3644 getOutputFd: () => Stream.empty,
3745 }),
38 ),
39 ),
46 );
47 }),
4048 );
4149
4250const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) =>
......@@ -50,7 +58,7 @@ const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) =>
5058
5159const resolveTargets = (input: {
5260 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 };
5462 readonly hostname: string;
5563}) =>
5664 Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe(
......@@ -62,11 +70,23 @@ const resolveTargets = (input: {
6270 ),
6371 );
6472
65const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON };
66const TAILSCALE_DOWN = { exitCode: 1, stdout: "" };
73// `tailscale debug prefs` output, trimmed. `tailscale up --ssh` sets RunSSH.
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
6888describe("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", () =>
7090 Effect.gen(function* () {
7191 const targets = yield* resolveTargets({
7292 sshd: { ipv4: false, ipv6: false },
......@@ -77,6 +97,28 @@ describe("RemoteOpenTargets", () => {
7797 }),
7898 );
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
80122 it.effect("orders the tailnet name before the mDNS name", () =>
81123 Effect.gen(function* () {
82124 const targets = yield* resolveTargets({
apps/server/src/environment/RemoteOpenTargets.ts+24-10
......@@ -3,15 +3,16 @@
33 * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`).
44 *
55 * The server can only check itself: sshd listening locally, tailscaled
6 * reporting a MagicDNS name, and the machine hostname for mDNS. Whether a
7 * given name resolves from the viewer's machine is inherently client-side.
6 * reporting a MagicDNS name and whether Tailscale SSH is on, and the machine
7 * hostname for mDNS. Whether a given name resolves from the viewer's machine
8 * is inherently client-side.
89 * Targets are ordered most-reachable first (tailnet name works from anywhere
910 * on the tailnet; `<hostname>.local` only on the same LAN).
1011 */
1112import { type RemoteOpenTarget } from "@t3tools/contracts";
1213import * as HostProcess from "@t3tools/shared/HostProcess";
1314import * as NetService from "@t3tools/shared/Net";
14import { readTailscaleStatus } from "@t3tools/tailscale";
15import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale";
1516import * as Context from "effect/Context";
1617import * as Effect from "effect/Effect";
1718import * as Layer from "effect/Layer";
......@@ -32,19 +33,12 @@ export const make = Effect.gen(function* () {
3233 const net = yield* NetService.NetService;
3334
3435 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.
3736 // Check both loopback families: sshd can be bound IPv6-only.
3837 const sshdListening = yield* Effect.zipWith(
3938 net.hasListenerOnHost(SSH_PORT, "127.0.0.1"),
4039 net.hasListenerOnHost(SSH_PORT, "::1"),
4140 (ipv4, ipv6) => ipv4 || ipv6,
4241 );
43 if (!sshdListening) {
44 return [];
45 }
46
47 const targets: Array<RemoteOpenTarget> = [];
4842
4943 // Tailscale absent or down is the common case, not an error.
5044 const magicDnsName = yield* readTailscaleStatus.pipe(
......@@ -52,6 +46,26 @@ export const make = Effect.gen(function* () {
5246 Effect.orElseSucceed(() => null),
5347 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
5448 );
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
5569 if (magicDnsName !== null) {
5670 targets.push({ kind: "tailscale", host: magicDnsName });
5771 }
packages/tailscale/src/tailscale.test.ts+22
......@@ -16,7 +16,9 @@ import {
1616 ensureTailscaleServe,
1717 isTailscaleIpv4Address,
1818 parseTailscaleMagicDnsName,
19 parseTailscaleSshEnabled,
1920 parseTailscaleStatus,
21 readTailscaleSshEnabled,
2022 readTailscaleStatus,
2123 TAILSCALE_STATUS_TIMEOUT,
2224 TailscaleCommandExitError,
......@@ -164,6 +166,26 @@ describe("tailscale", () => {
164166 }),
165167 );
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
167189 it.effect("builds clean HTTPS base URLs", () =>
168190 Effect.sync(() => {
169191 assert.equal(
packages/tailscale/src/tailscale.ts+85-56
......@@ -19,7 +19,7 @@ const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "
1919
2020const TailscaleCommandContext = {
2121 executable: Schema.Literals(["tailscale", "tailscale.exe"]),
22 subcommand: Schema.Literals(["status", "serve"]),
22 subcommand: Schema.Literals(["status", "serve", "debug"]),
2323 argumentCount: Schema.Number,
2424};
2525
......@@ -214,64 +214,93 @@ export const parseTailscaleStatus = (
214214 }),
215215 );
216216
217export const readTailscaleStatus = Effect.gen(function* () {
218 const args = ["status", "--json"];
219 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
220 const hostPlatform = yield* HostProcess.Platform;
221 const executable = tailscaleCommandForPlatform(hostPlatform);
222 const commandContext = {
223 executable,
224 subcommand: "status" as const,
225 argumentCount: args.length,
226 };
227 return yield* Effect.gen(function* () {
228 const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe(
229 Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
230 // Spawning can also fail as a defect rather than a typed error - a
231 // non-directory entry on PATH makes node throw ENOTDIR synchronously.
232 // `mapError` never sees that, so it would escape as an uncaught error.
233 Effect.catchDefect((cause) =>
234 Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })),
235 ),
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 }),
217/** Runs a read-only tailscale command and returns its stdout. */
218const readTailscaleOutput = (subcommand: "status" | "debug", args: readonly string[]) =>
219 Effect.gen(function* () {
220 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
221 const hostPlatform = yield* HostProcess.Platform;
222 const executable = tailscaleCommandForPlatform(hostPlatform);
223 const commandContext = {
224 executable,
225 subcommand,
226 argumentCount: args.length,
227 };
228 return yield* Effect.gen(function* () {
229 const child = yield* spawner.spawn(ChildProcess.make(executable, [...args])).pipe(
230 Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
231 // Spawning can also fail as a defect rather than a typed error - a
232 // non-directory entry on PATH makes node throw ENOTDIR synchronously.
233 // `mapError` never sees that, so it would escape as an uncaught error.
234 Effect.catchDefect((cause) =>
235 Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })),
270236 ),
271 }),
272 );
237 );
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),
273283});
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
275304export function buildTailscaleHttpsBaseUrl(input: {
276305 readonly magicDnsName: string;
277306 readonly servePort?: number;