1import { assert, describe, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";
6import * as PlatformError from "effect/PlatformError";
7import * as Sink from "effect/Sink";
8import * as Stream from "effect/Stream";
9import * as TestClock from "effect/testing/TestClock";
10import { ChildProcessSpawner } from "effect/process";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12
13import {
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
30const 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 */
41function 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}
69const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`;
70const tailscaleStatusWithSingleIpJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`;
71
72function 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
88function 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.
106function layerSpawner(spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) {
107 return Layer.merge(
108 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
109 Layer.succeed(HostProcess.Platform, "linux"),
110 );
111}
112
113function 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
130describe("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});