| 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,6 +5,7 @@ import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Sink from "effect/Sink"; | 6 | import * as Sink from "effect/Sink"; |
| 7 | import * as Stream from "effect/Stream"; | 7 | import * as Stream from "effect/Stream"; |
| 8 | import * as ChildProcess from "effect/process/ChildProcess"; | ||
| 8 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | 9 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 9 | import { describe, expect } from "vite-plus/test"; | 10 | import { describe, expect } from "vite-plus/test"; |
| 10 | 11 | ||
| ... | @@ -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 | }); |
| 18 | 19 | ||
| 19 | /** Spawner whose `tailscale status --json` exits with the given output. */ | 20 | interface CommandResult { |
| 20 | const 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`. */ | ||
| 26 | const 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 | ); |
| 41 | 49 | ||
| 42 | const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => | 50 | const 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 }) => |
| 50 | 58 | ||
| 51 | const resolveTargets = (input: { | 59 | const 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 | ); |
| 64 | 72 | ||
| 65 | const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON }; | 73 | // `tailscale debug prefs` output, trimmed. `tailscale up --ssh` sets RunSSH. |
| 66 | const TAILSCALE_DOWN = { exitCode: 1, stdout: "" }; | 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 | describe("RemoteOpenTargets", () => { | 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 | 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 | ); |
| 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 | 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, tailscaled | 5 | * The server can only check itself: sshd listening locally, tailscaled |
| 6 | * reporting a MagicDNS name, and the machine hostname for mDNS. Whether a | 6 | * 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 anywhere | 9 | * 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 | */ |
| 11 | import { type RemoteOpenTarget } from "@t3tools/contracts"; | 12 | import { type RemoteOpenTarget } from "@t3tools/contracts"; |
| 12 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 13 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 13 | import * as NetService from "@t3tools/shared/Net"; | 14 | import * as NetService from "@t3tools/shared/Net"; |
| 14 | import { readTailscaleStatus } from "@t3tools/tailscale"; | 15 | import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale"; |
| 15 | import * as Context from "effect/Context"; | 16 | import * as Context from "effect/Context"; |
| 16 | import * as Effect from "effect/Effect"; | 17 | import * as Effect from "effect/Effect"; |
| 17 | import * as Layer from "effect/Layer"; | 18 | import * 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; |
| 33 | 34 | ||
| 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> = []; | ||
| 48 | 42 | ||
| 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 | ); |
| 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 | 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" | " |
| 19 | 19 | ||
| 20 | const TailscaleCommandContext = { | 20 | const 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 | }; |
| 25 | 25 | ||
| ... | @@ -214,64 +214,93 @@ export const parseTailscaleStatus = ( | ... | @@ -214,64 +214,93 @@ export const parseTailscaleStatus = ( |
| 214 | }), | 214 | }), |
| 215 | ); | 215 | ); |
| 216 | 216 | ||
| 217 | export const readTailscaleStatus = Effect.gen(function* () { | 217 | /** Runs a read-only tailscale command and returns its stdout. */ |
| 218 | const args = ["status", "--json"]; | 218 | const 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 - a | 230 | 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 | |||
| 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 | export function buildTailscaleHttpsBaseUrl(input: { | 304 | export function buildTailscaleHttpsBaseUrl(input: { |
| 276 | readonly magicDnsName: string; | 305 | readonly magicDnsName: string; |
| 277 | readonly servePort?: number; | 306 | readonly servePort?: number; |