| 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as Cause from "effect/Cause"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as PlatformError from "effect/PlatformError"; |
| 7 | import * as Sink from "effect/Sink"; |
| 8 | import * as Stream from "effect/Stream"; |
| 9 | import * as TestClock from "effect/testing/TestClock"; |
| 10 | import { ChildProcessSpawner } from "effect/process"; |
| 11 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 12 | |
| 13 | import { |
| 14 | buildTailscaleHttpsBaseUrl, |
| 15 | disableTailscaleServe, |
| 16 | ensureTailscaleServe, |
| 17 | isTailscaleIpv4Address, |
| 18 | parseTailscaleMagicDnsName, |
| 19 | parseTailscaleSshEnabled, |
| 20 | parseTailscaleStatus, |
| 21 | readTailscaleSshEnabled, |
| 22 | readTailscaleStatus, |
| 23 | TAILSCALE_STATUS_TIMEOUT, |
| 24 | TailscaleCommandExitError, |
| 25 | TailscaleCommandSpawnError, |
| 26 | TailscaleCommandTimeoutError, |
| 27 | TailscaleStatusParseError, |
| 28 | } from "./tailscale.ts"; |
| 29 | |
| 30 | const encoder = new TextEncoder(); |
| 31 | |
| 32 | /** |
| 33 | * Asserts nothing reachable from `error` contains `secret`. Recurses through |
| 34 | * nested objects, arrays, and `cause` chains rather than checking only |
| 35 | * top-level strings: a leak one level down (say, a wrapped cause carrying raw |
| 36 | * stderr) is just as visible in a log, and a shallow check would pass it. |
| 37 | * |
| 38 | * Walks values instead of serializing so it holds for fields added later, and |
| 39 | * tracks visited objects so a cyclic cause chain terminates. |
| 40 | */ |
| 41 | function assertCarriesNoSecret(error: object, secret: string): void { |
| 42 | const seen = new WeakSet<object>(); |
| 43 | |
| 44 | const walk = (value: unknown, path: string): void => { |
| 45 | if (typeof value === "string") { |
| 46 | assert.notInclude(value, secret, `${path} leaked stderr`); |
| 47 | return; |
| 48 | } |
| 49 | if (typeof value !== "object" || value === null || seen.has(value)) { |
| 50 | return; |
| 51 | } |
| 52 | seen.add(value); |
| 53 | |
| 54 | if (Array.isArray(value)) { |
| 55 | value.forEach((entry, index) => walk(entry, `${path}[${String(index)}]`)); |
| 56 | return; |
| 57 | } |
| 58 | // `message` and `cause` are getters on Error subclasses, so they are not |
| 59 | // own enumerable properties and Object.entries alone would skip them. |
| 60 | walk((value as { message?: unknown }).message, `${path}.message`); |
| 61 | walk((value as { cause?: unknown }).cause, `${path}.cause`); |
| 62 | for (const [key, nested] of Object.entries(value)) { |
| 63 | walk(nested, `${path}.${key}`); |
| 64 | } |
| 65 | }; |
| 66 | |
| 67 | walk(error, "error"); |
| 68 | } |
| 69 | const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`; |
| 70 | const tailscaleStatusWithSingleIpJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`; |
| 71 | |
| 72 | function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) { |
| 73 | return ChildProcessSpawner.makeHandle({ |
| 74 | pid: ChildProcessSpawner.ProcessId(1), |
| 75 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), |
| 76 | isRunning: Effect.succeed(false), |
| 77 | kill: () => Effect.void, |
| 78 | unref: Effect.succeed(Effect.void), |
| 79 | stdin: Sink.drain, |
| 80 | stdout: Stream.make(encoder.encode(result.stdout ?? "")), |
| 81 | stderr: Stream.make(encoder.encode(result.stderr ?? "")), |
| 82 | all: Stream.empty, |
| 83 | getInputFd: () => Sink.drain, |
| 84 | getOutputFd: () => Stream.empty, |
| 85 | }); |
| 86 | } |
| 87 | |
| 88 | function neverFinishingMockHandle() { |
| 89 | return ChildProcessSpawner.makeHandle({ |
| 90 | pid: ChildProcessSpawner.ProcessId(1), |
| 91 | exitCode: Effect.never, |
| 92 | isRunning: Effect.succeed(true), |
| 93 | kill: () => Effect.void, |
| 94 | unref: Effect.succeed(Effect.void), |
| 95 | stdin: Sink.drain, |
| 96 | stdout: Stream.empty, |
| 97 | stderr: Stream.empty, |
| 98 | all: Stream.empty, |
| 99 | getInputFd: () => Sink.drain, |
| 100 | getOutputFd: () => Stream.empty, |
| 101 | }); |
| 102 | } |
| 103 | |
| 104 | // The executable name depends on the host platform (`tailscale.exe` on |
| 105 | // Windows), so pin it: these tests assert the posix spelling. |
| 106 | function layerSpawner(spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) { |
| 107 | return Layer.merge( |
| 108 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 109 | Layer.succeed(HostProcess.Platform, "linux"), |
| 110 | ); |
| 111 | } |
| 112 | |
| 113 | function layerMockSpawner( |
| 114 | handler: ( |
| 115 | command: string, |
| 116 | args: ReadonlyArray<string>, |
| 117 | ) => { stdout?: string; stderr?: string; code?: number }, |
| 118 | ) { |
| 119 | return layerSpawner( |
| 120 | ChildProcessSpawner.make((command) => { |
| 121 | const childProcess = command as unknown as { |
| 122 | readonly command: string; |
| 123 | readonly args: ReadonlyArray<string>; |
| 124 | }; |
| 125 | return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args))); |
| 126 | }), |
| 127 | ); |
| 128 | } |
| 129 | |
| 130 | describe("tailscale", () => { |
| 131 | it.effect("detects Tailnet IPv4 addresses", () => |
| 132 | Effect.sync(() => { |
| 133 | assert.equal(isTailscaleIpv4Address("100.64.0.1"), true); |
| 134 | assert.equal(isTailscaleIpv4Address("100.127.255.254"), true); |
| 135 | assert.equal(isTailscaleIpv4Address("100.128.0.1"), false); |
| 136 | assert.equal(isTailscaleIpv4Address("192.168.1.44"), false); |
| 137 | }), |
| 138 | ); |
| 139 | |
| 140 | it.effect("parses MagicDNS names from tailscale status", () => |
| 141 | Effect.gen(function* () { |
| 142 | const dnsName = yield* parseTailscaleMagicDnsName(tailscaleStatusJson); |
| 143 | assert.equal(dnsName, "desktop.tail.ts.net"); |
| 144 | assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); |
| 145 | }), |
| 146 | ); |
| 147 | |
| 148 | it.effect("parses status facts", () => |
| 149 | Effect.gen(function* () { |
| 150 | const status = yield* parseTailscaleStatus(tailscaleStatusJson); |
| 151 | assert.deepEqual(status, { |
| 152 | magicDnsName: "desktop.tail.ts.net", |
| 153 | tailnetIpv4Addresses: ["100.100.100.100"], |
| 154 | }); |
| 155 | }), |
| 156 | ); |
| 157 | |
| 158 | it.effect("preserves status decoding failures without exposing cause text", () => |
| 159 | Effect.gen(function* () { |
| 160 | const error = yield* parseTailscaleStatus("{not-json").pipe(Effect.flip); |
| 161 | |
| 162 | assert.instanceOf(error, TailscaleStatusParseError); |
| 163 | assert.equal(error.message, "Failed to decode tailscale status JSON."); |
| 164 | assert.isDefined(error.cause); |
| 165 | assert.notInclude(error.message, String(error.cause)); |
| 166 | }), |
| 167 | ); |
| 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 | |
| 189 | it.effect("builds clean HTTPS base URLs", () => |
| 190 | Effect.sync(() => { |
| 191 | assert.equal( |
| 192 | buildTailscaleHttpsBaseUrl({ magicDnsName: "desktop.tail.ts.net" }), |
| 193 | "https://desktop.tail.ts.net/", |
| 194 | ); |
| 195 | assert.equal( |
| 196 | buildTailscaleHttpsBaseUrl({ magicDnsName: "desktop.tail.ts.net", servePort: 8443 }), |
| 197 | "https://desktop.tail.ts.net:8443/", |
| 198 | ); |
| 199 | }), |
| 200 | ); |
| 201 | |
| 202 | it.effect("reads tailscale status through the process spawner service", () => { |
| 203 | const layer = layerMockSpawner((command, args) => { |
| 204 | assert.equal(command, "tailscale"); |
| 205 | assert.deepEqual(args, ["status", "--json"]); |
| 206 | return { |
| 207 | stdout: tailscaleStatusWithSingleIpJson, |
| 208 | }; |
| 209 | }); |
| 210 | |
| 211 | return Effect.gen(function* () { |
| 212 | const status = yield* readTailscaleStatus.pipe(Effect.provide(layer)); |
| 213 | assert.deepEqual(status, { |
| 214 | magicDnsName: "desktop.tail.ts.net", |
| 215 | tailnetIpv4Addresses: ["100.90.1.2"], |
| 216 | }); |
| 217 | }); |
| 218 | }); |
| 219 | |
| 220 | it.effect("preserves tailscale spawn failures as causes", () => { |
| 221 | const systemCause = new Error("private executable lookup detail"); |
| 222 | const cause = PlatformError.systemError({ |
| 223 | _tag: "NotFound", |
| 224 | module: "ChildProcess", |
| 225 | method: "spawn", |
| 226 | cause: systemCause, |
| 227 | }); |
| 228 | const layer = layerSpawner(ChildProcessSpawner.make(() => Effect.fail(cause))); |
| 229 | |
| 230 | return Effect.gen(function* () { |
| 231 | const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); |
| 232 | |
| 233 | assert.instanceOf(error, TailscaleCommandSpawnError); |
| 234 | assert.equal(error.executable, "tailscale"); |
| 235 | assert.equal(error.subcommand, "status"); |
| 236 | assert.equal(error.argumentCount, 2); |
| 237 | assert.strictEqual(error.cause, cause); |
| 238 | assert.equal(error.message, "Failed to spawn tailscale status."); |
| 239 | assert.notInclude(error.message, systemCause.message); |
| 240 | }); |
| 241 | }); |
| 242 | |
| 243 | it.effect("turns spawn defects into typed spawn failures", () => { |
| 244 | // A non-directory entry on PATH makes node's spawn throw ENOTDIR |
| 245 | // synchronously. The platform spawner calls `NodeChildProcess.spawn` from |
| 246 | // inside an `Effect.callback` registration, so that throw arrives as a |
| 247 | // defect rather than a typed error - the shape reproduced here. |
| 248 | const defect = Object.assign(new Error("spawn tailscale ENOTDIR"), { code: "ENOTDIR" }); |
| 249 | const layer = layerSpawner( |
| 250 | ChildProcessSpawner.make(() => |
| 251 | Effect.callback<never, never>(() => { |
| 252 | throw defect; |
| 253 | }), |
| 254 | ), |
| 255 | ); |
| 256 | |
| 257 | return Effect.gen(function* () { |
| 258 | const statusError = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); |
| 259 | assert.instanceOf(statusError, TailscaleCommandSpawnError); |
| 260 | assert.equal(statusError.subcommand, "status"); |
| 261 | assert.strictEqual(statusError.cause, defect); |
| 262 | |
| 263 | const serveError = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( |
| 264 | Effect.flip, |
| 265 | Effect.provide(layer), |
| 266 | ); |
| 267 | assert.instanceOf(serveError, TailscaleCommandSpawnError); |
| 268 | assert.equal(serveError.subcommand, "serve"); |
| 269 | assert.strictEqual(serveError.cause, defect); |
| 270 | |
| 271 | // What callers actually rely on: the desktop endpoint providers recover |
| 272 | // with `Effect.orElseSucceed`, which only sees the typed error channel. |
| 273 | const degraded = yield* readTailscaleStatus.pipe( |
| 274 | Effect.orElseSucceed(() => null), |
| 275 | Effect.provide(layer), |
| 276 | ); |
| 277 | assert.equal(degraded, null); |
| 278 | }); |
| 279 | }); |
| 280 | |
| 281 | it.effect("keeps nonzero exit diagnostics structured", () => { |
| 282 | const layer = layerMockSpawner(() => ({ |
| 283 | code: 7, |
| 284 | stderr: "not logged in tskey-auth-secret-token-value", |
| 285 | })); |
| 286 | |
| 287 | return Effect.gen(function* () { |
| 288 | const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); |
| 289 | |
| 290 | assert.instanceOf(error, TailscaleCommandExitError); |
| 291 | assert.equal(error.executable, "tailscale"); |
| 292 | assert.equal(error.subcommand, "status"); |
| 293 | assert.equal(error.argumentCount, 2); |
| 294 | assert.equal(error.exitCode, 7); |
| 295 | assert.equal(error.stdoutLength, 0); |
| 296 | assert.equal(error.stderrLength, 43); |
| 297 | assert.notProperty(error, "command"); |
| 298 | assert.notProperty(error, "stderr"); |
| 299 | assert.notInclude(error.message, "tskey-auth-secret-token-value"); |
| 300 | assert.equal(error.message, "tailscale status exited with code 7."); |
| 301 | assert.equal(error.stderrDiagnostic, "not-logged-in"); |
| 302 | assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); |
| 303 | }); |
| 304 | }); |
| 305 | |
| 306 | it.effect("classifies unrecognized stderr without quoting it", () => { |
| 307 | const layer = layerMockSpawner(() => ({ |
| 308 | code: 3, |
| 309 | stderr: "something novel went wrong for node fluffy-badger tskey-auth-secret-token-value", |
| 310 | })); |
| 311 | |
| 312 | return Effect.gen(function* () { |
| 313 | const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); |
| 314 | |
| 315 | assert.instanceOf(error, TailscaleCommandExitError); |
| 316 | // Unmatched stderr degrades to "unknown" rather than passing text |
| 317 | // through — that fallback is what keeps novel output from leaking. |
| 318 | assert.equal(error.stderrDiagnostic, "unknown"); |
| 319 | assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); |
| 320 | assertCarriesNoSecret(error, "fluffy-badger"); |
| 321 | }); |
| 322 | }); |
| 323 | |
| 324 | it.effect("times out tailscale status through TestClock", () => { |
| 325 | const layer = Layer.merge( |
| 326 | TestClock.layer(), |
| 327 | layerSpawner(ChildProcessSpawner.make(() => Effect.succeed(neverFinishingMockHandle()))), |
| 328 | ); |
| 329 | |
| 330 | return Effect.gen(function* () { |
| 331 | const fiber = yield* readTailscaleStatus.pipe(Effect.flip, Effect.forkScoped); |
| 332 | yield* Effect.yieldNow; |
| 333 | yield* TestClock.adjust(TAILSCALE_STATUS_TIMEOUT); |
| 334 | const error = yield* Fiber.join(fiber); |
| 335 | |
| 336 | assert.instanceOf(error, TailscaleCommandTimeoutError); |
| 337 | assert.equal(error.executable, "tailscale"); |
| 338 | assert.equal(error.subcommand, "status"); |
| 339 | assert.equal(error.argumentCount, 2); |
| 340 | assert.equal(error.timeoutMs, 1_500); |
| 341 | assert.isTrue(Cause.isTimeoutError(error.cause)); |
| 342 | assert.equal(error.message, "tailscale status timed out after 1500ms."); |
| 343 | }).pipe(Effect.provide(layer)); |
| 344 | }); |
| 345 | |
| 346 | it.effect("configures tailscale serve through the process spawner service", () => { |
| 347 | const layer = layerMockSpawner((command, args) => { |
| 348 | assert.equal(command, "tailscale"); |
| 349 | assert.deepEqual(args, ["serve", "--bg", "--https=8443", "http://127.0.0.1:13773"]); |
| 350 | return {}; |
| 351 | }); |
| 352 | |
| 353 | return ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(Effect.provide(layer)); |
| 354 | }); |
| 355 | |
| 356 | it.effect("retains tailscale serve exit diagnostics", () => { |
| 357 | const layer = layerMockSpawner(() => ({ |
| 358 | code: 1, |
| 359 | stderr: "serve permission denied tskey-auth-secret-token-value", |
| 360 | })); |
| 361 | |
| 362 | return Effect.gen(function* () { |
| 363 | const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( |
| 364 | Effect.flip, |
| 365 | Effect.provide(layer), |
| 366 | ); |
| 367 | |
| 368 | assert.instanceOf(error, TailscaleCommandExitError); |
| 369 | assert.equal(error.executable, "tailscale"); |
| 370 | assert.equal(error.subcommand, "serve"); |
| 371 | assert.equal(error.argumentCount, 4); |
| 372 | assert.equal(error.exitCode, 1); |
| 373 | assert.equal(error.stderrLength, 53); |
| 374 | assert.notProperty(error, "command"); |
| 375 | assert.notProperty(error, "stderr"); |
| 376 | assert.notInclude(error.message, "tskey-auth-secret-token-value"); |
| 377 | // The diagnostic classifies the failure without quoting stderr, so the |
| 378 | // key cannot reach a log through it either. |
| 379 | assert.equal(error.stderrDiagnostic, "permission-denied"); |
| 380 | assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); |
| 381 | }); |
| 382 | }); |
| 383 | |
| 384 | it.effect("disables tailscale serve through the process spawner service", () => { |
| 385 | const commands: { |
| 386 | readonly command: string; |
| 387 | readonly args: ReadonlyArray<string>; |
| 388 | }[] = []; |
| 389 | const layer = layerMockSpawner((command, args) => { |
| 390 | commands.push({ command, args }); |
| 391 | assert.equal(command, "tailscale"); |
| 392 | assert.deepEqual(args, ["serve", "--https=8443", "off"]); |
| 393 | return {}; |
| 394 | }); |
| 395 | |
| 396 | return Effect.gen(function* () { |
| 397 | yield* disableTailscaleServe({ servePort: 8443 }).pipe(Effect.provide(layer)); |
| 398 | assert.deepEqual(commands, [ |
| 399 | { command: "tailscale", args: ["serve", "--https=8443", "off"] }, |
| 400 | ]); |
| 401 | }); |
| 402 | }); |
| 403 | }); |