| author | |
| committer | |
| log | b037b9a535ff30f3f390da682dabeffe4c439044 |
| tree | b9a7c2e01879b7fcdc06ec7e006b647d1e4d122d |
| parent | 0796638512d4c610469905adc597cc33c307fc32 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Sink from "effect/Sink"; |
| 7 | 7 | import * as Stream from "effect/Stream"; |
| 8 | import * as ChildProcess from "effect/process/ChildProcess"; | |
| 8 | 9 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 9 | 10 | import { describe, expect } from "vite-plus/test"; |
| 10 | 11 | |
| ... | ... | @@ -16,27 +17,34 @@ const TAILSCALE_STATUS_JSON = JSON.stringify({ |
| 16 | 17 | Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, |
| 17 | 18 | }); |
| 18 | 19 | |
| 19 | /** Spawner whose `tailscale status --json` exits with the given output. */ | |
| 20 | const layerSpawner = (input: { readonly exitCode: number; readonly stdout: string }) => | |
| 20 | interface CommandResult { | |
| 21 | readonly exitCode: number; | |
| 22 | readonly stdout: string; | |
| 23 | } | |
| 24 | ||
| 25 | /** Spawner answering `tailscale status --json` and `tailscale debug prefs`. */ | |
| 26 | const layerSpawner = (input: { readonly status: CommandResult; readonly prefs: CommandResult }) => | |
| 21 | 27 | Layer.succeed( |
| 22 | 28 | 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( | |
| 25 | 33 | ChildProcessSpawner.makeHandle({ |
| 26 | 34 | pid: ChildProcessSpawner.ProcessId(1), |
| 27 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)), | |
| 35 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), | |
| 28 | 36 | isRunning: Effect.succeed(false), |
| 29 | 37 | kill: () => Effect.void, |
| 30 | 38 | unref: Effect.succeed(Effect.void), |
| 31 | 39 | stdin: Sink.drain, |
| 32 | stdout: Stream.make(encoder.encode(input.stdout)), | |
| 40 | stdout: Stream.make(encoder.encode(result.stdout)), | |
| 33 | 41 | stderr: Stream.empty, |
| 34 | 42 | all: Stream.empty, |
| 35 | 43 | getInputFd: () => Sink.drain, |
| 36 | 44 | getOutputFd: () => Stream.empty, |
| 37 | 45 | }), |
| 38 | ), | |
| 39 | ), | |
| 46 | ); | |
| 47 | }), | |
| 40 | 48 | ); |
| 41 | 49 | |
| 42 | 50 | const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => |
| ... | ... | @@ -50,7 +58,7 @@ const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => |
| 50 | 58 | |
| 51 | 59 | const resolveTargets = (input: { |
| 52 | 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 | 62 | readonly hostname: string; |
| 55 | 63 | }) => |
| 56 | 64 | Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe( |
| ... | ... | @@ -62,11 +70,23 @@ const resolveTargets = (input: { |
| 62 | 70 | ), |
| 63 | 71 | ); |
| 64 | 72 | |
| 65 | const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON }; | |
| 66 | const TAILSCALE_DOWN = { exitCode: 1, stdout: "" }; | |
| 73 | // `tailscale debug prefs` output, trimmed. `tailscale up --ssh` sets RunSSH. | |
| 74 | const 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 | ||
| 83 | const STATUS_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON }; | |
| 84 | const TAILSCALE_UP = { status: STATUS_UP, prefs: prefsJson({ RunSSH: false, WantRunning: true }) }; | |
| 85 | const TAILSCALE_SSH = { status: STATUS_UP, prefs: prefsJson({ RunSSH: true, WantRunning: true }) }; | |
| 86 | const TAILSCALE_DOWN = { status: { exitCode: 1, stdout: "" }, prefs: { exitCode: 1, stdout: "" } }; | |
| 67 | 87 | |
| 68 | 88 | describe("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 | 90 | Effect.gen(function* () { |
| 71 | 91 | const targets = yield* resolveTargets({ |
| 72 | 92 | sshd: { ipv4: false, ipv6: false }, |
| ... | ... | @@ -77,6 +97,28 @@ describe("RemoteOpenTargets", () => { |
| 77 | 97 | }), |
| 78 | 98 | ); |
| 79 | 99 | |
| 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 | 122 | it.effect("orders the tailnet name before the mDNS name", () => |
| 81 | 123 | Effect.gen(function* () { |
| 82 | 124 | const targets = yield* resolveTargets({ |
apps/server/src/environment/RemoteOpenTargets.ts+24-10| ... | ... | @@ -3,15 +3,16 @@ |
| 3 | 3 | * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`). |
| 4 | 4 | * |
| 5 | 5 | * 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. | |
| 8 | 9 | * Targets are ordered most-reachable first (tailnet name works from anywhere |
| 9 | 10 | * on the tailnet; `<hostname>.local` only on the same LAN). |
| 10 | 11 | */ |
| 11 | 12 | import { type RemoteOpenTarget } from "@t3tools/contracts"; |
| 12 | 13 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 13 | 14 | import * as NetService from "@t3tools/shared/Net"; |
| 14 | import { readTailscaleStatus } from "@t3tools/tailscale"; | |
| 15 | import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale"; | |
| 15 | 16 | import * as Context from "effect/Context"; |
| 16 | 17 | import * as Effect from "effect/Effect"; |
| 17 | 18 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -32,19 +33,12 @@ export const make = Effect.gen(function* () { |
| 32 | 33 | const net = yield* NetService.NetService; |
| 33 | 34 | |
| 34 | 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 | 36 | // Check both loopback families: sshd can be bound IPv6-only. |
| 38 | 37 | const sshdListening = yield* Effect.zipWith( |
| 39 | 38 | net.hasListenerOnHost(SSH_PORT, "127.0.0.1"), |
| 40 | 39 | net.hasListenerOnHost(SSH_PORT, "::1"), |
| 41 | 40 | (ipv4, ipv6) => ipv4 || ipv6, |
| 42 | 41 | ); |
| 43 | if (!sshdListening) { | |
| 44 | return []; | |
| 45 | } | |
| 46 | ||
| 47 | const targets: Array<RemoteOpenTarget> = []; | |
| 48 | 42 | |
| 49 | 43 | // Tailscale absent or down is the common case, not an error. |
| 50 | 44 | const magicDnsName = yield* readTailscaleStatus.pipe( |
| ... | ... | @@ -52,6 +46,26 @@ export const make = Effect.gen(function* () { |
| 52 | 46 | Effect.orElseSucceed(() => null), |
| 53 | 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 | 69 | if (magicDnsName !== null) { |
| 56 | 70 | targets.push({ kind: "tailscale", host: magicDnsName }); |
| 57 | 71 | } |
packages/tailscale/src/tailscale.test.ts+22| ... | ... | @@ -16,7 +16,9 @@ import { |
| 16 | 16 | ensureTailscaleServe, |
| 17 | 17 | isTailscaleIpv4Address, |
| 18 | 18 | parseTailscaleMagicDnsName, |
| 19 | parseTailscaleSshEnabled, | |
| 19 | 20 | parseTailscaleStatus, |
| 21 | readTailscaleSshEnabled, | |
| 20 | 22 | readTailscaleStatus, |
| 21 | 23 | TAILSCALE_STATUS_TIMEOUT, |
| 22 | 24 | TailscaleCommandExitError, |
| ... | ... | @@ -164,6 +166,26 @@ describe("tailscale", () => { |
| 164 | 166 | }), |
| 165 | 167 | ); |
| 166 | 168 | |
| 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 | 189 | it.effect("builds clean HTTPS base URLs", () => |
| 168 | 190 | Effect.sync(() => { |
| 169 | 191 | assert.equal( |
packages/tailscale/src/tailscale.ts+85-56| ... | ... | @@ -19,7 +19,7 @@ const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | " |
| 19 | 19 | |
| 20 | 20 | const TailscaleCommandContext = { |
| 21 | 21 | executable: Schema.Literals(["tailscale", "tailscale.exe"]), |
| 22 | subcommand: Schema.Literals(["status", "serve"]), | |
| 22 | subcommand: Schema.Literals(["status", "serve", "debug"]), | |
| 23 | 23 | argumentCount: Schema.Number, |
| 24 | 24 | }; |
| 25 | 25 | |
| ... | ... | @@ -214,64 +214,93 @@ export const parseTailscaleStatus = ( |
| 214 | 214 | }), |
| 215 | 215 | ); |
| 216 | 216 | |
| 217 | export 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. */ | |
| 218 | const 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 })), | |
| 270 | 236 | ), |
| 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 | ||
| 276 | export const readTailscaleStatus = readTailscaleOutput("status", ["status", "--json"]).pipe( | |
| 277 | Effect.flatMap(parseTailscaleStatus), | |
| 278 | ); | |
| 279 | ||
| 280 | const TailscalePrefsJson = Schema.Struct({ | |
| 281 | RunSSH: Schema.optional(Schema.Unknown), | |
| 282 | WantRunning: Schema.optional(Schema.Unknown), | |
| 273 | 283 | }); |
| 274 | 284 | |
| 285 | const 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 | */ | |
| 292 | export 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 | ||
| 300 | export const readTailscaleSshEnabled = readTailscaleOutput("debug", ["debug", "prefs"]).pipe( | |
| 301 | Effect.flatMap(parseTailscaleSshEnabled), | |
| 302 | ); | |
| 303 | ||
| 275 | 304 | export function buildTailscaleHttpsBaseUrl(input: { |
| 276 | 305 | readonly magicDnsName: string; |
| 277 | 306 | readonly servePort?: number; |