Skip to content
Source
Sign inSign up

tailscale.test.ts

498 lines18.9 KB

1import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import { HttpClient, HttpClientResponse } from "effect/http";
11import * as TestClock from "effect/testing/TestClock";
12import { ChildProcessSpawner } from "effect/process";
13import * as HostProcess from "@t3tools/shared/HostProcess";
14​
15import {
16 ensureTailscaleServe,
17 parseTailscaleExitNode,
18 parseTailscaleServeRootHolders,
19 parseTailscaleServeRoutesTo,
20 parseTailscaleSshEnabled,
21 parseTailscaleStatus,
22 probeTailscaleHttpsEndpoint,
23 readTailscaleSshEnabled,
24 readTailscaleStatus,
25 TailscaleCommandExitError,
26 TailscaleCommandSpawnError,
27 TailscaleStatusParseError,
28} from "./tailscale.ts";
29​
30const encoder = new TextEncoder();
31​
32function assertCarriesNoSecret(error: object, secret: string): void {
33 const seen = new WeakSet<object>();
34​
35 const walk = (value: unknown, path: string): void => {
36 if (typeof value === "string") {
37 assert.notInclude(value, secret, `${path} leaked stderr`);
38 return;
39 }
40 if (typeof value !== "object" || value === null || seen.has(value)) {
41 return;
42 }
43 seen.add(value);
44​
45 if (Array.isArray(value)) {
46 value.forEach((entry, index) => walk(entry, `${path}[${String(index)}]`));
47 return;
48 }
49 // `message` and `cause` are getters on Error subclasses, so they are not
50 // own enumerable properties and Object.entries alone would skip them.
51 walk((value as { message?: unknown }).message, `${path}.message`);
52 walk((value as { cause?: unknown }).cause, `${path}.cause`);
53 for (const [key, nested] of Object.entries(value)) {
54 walk(nested, `${path}.${key}`);
55 }
56 };
57​
58 walk(error, "error");
60const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`;
61​
62function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) {
63 return ChildProcessSpawner.makeHandle({
64 pid: ChildProcessSpawner.ProcessId(1),
65 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)),
66 isRunning: Effect.succeed(false),
67 kill: () => Effect.void,
68 unref: Effect.succeed(Effect.void),
69 stdin: Sink.drain,
70 stdout: Stream.make(encoder.encode(result.stdout ?? "")),
71 stderr: Stream.make(encoder.encode(result.stderr ?? "")),
72 all: Stream.empty,
73 getInputFd: () => Sink.drain,
74 getOutputFd: () => Stream.empty,
75 });
77​
78// The executable name depends on the host platform (`tailscale.exe` on
79// Windows), so pin it: these tests assert the posix spelling.
80function layerSpawner(spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) {
81 return Layer.merge(
82 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
83 Layer.succeed(HostProcess.Platform, "linux"),
84 );
86​
87function layerMockSpawner(
88 handler: (
89 command: string,
90 args: ReadonlyArray<string>,
91 ) => { stdout?: string; stderr?: string; code?: number },
92) {
93 return layerSpawner(
94 ChildProcessSpawner.make((command) => {
95 const childProcess = command as unknown as {
96 readonly command: string;
97 readonly args: ReadonlyArray<string>;
98 };
99 return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args)));
100 }),
101 );
103​
104describe("tailscale", () => {
105 it.effect("does not treat a non-T3 response on the HTTPS probe path as reachable", () =>
106 Effect.gen(function* () {
107 for (const [body, status] of [
108 ["<!doctype html><title>Other application</title>", 200],
109 [null, 204],
110 ] as const) {
111 const reachable = yield* probeTailscaleHttpsEndpoint({
112 baseUrl: "https://desktop.example.ts.net",
113 environmentId: "this-environment",
114 }).pipe(
115 Effect.provideService(
116 HttpClient.HttpClient,
117 HttpClient.make((request) =>
118 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body, { status }))),
119 ),
120 ),
121 );
122 assert.isFalse(reachable, `HTTP ${status} without a T3 descriptor`);
123 }
124 }),
125 );
126​
127 it.effect("does not treat another T3 server behind the Serve URL as this server", () =>
128 Effect.gen(function* () {
129 const answer = (environmentId: string) =>
130 HttpClient.make((request) =>
131 Effect.succeed(
132 HttpClientResponse.fromWeb(
133 request,
134 Response.json({
135 environmentId,
136 label: "T3 server",
137 platform: { os: "linux", arch: "x64" },
138 serverVersion: "0.1.9",
139 capabilities: { repositoryIdentity: true },
140 }),
141 ),
142 ),
143 );
144 const probe = (served: string) =>
145 probeTailscaleHttpsEndpoint({
146 baseUrl: "https://desktop.example.ts.net",
147 environmentId: "this-environment",
148 }).pipe(Effect.provideService(HttpClient.HttpClient, answer(served)));
149​
150 assert.isTrue(yield* probe("this-environment"));
151 assert.isFalse(yield* probe("another-environment"));
152 }),
153 );
154​
155 it("finds the Serve routes that forward to the bound address, and only those", () => {
156 const status = JSON.stringify({
157 TCP: {
158 "443": { HTTPS: true },
159 "7000": { TCPForward: "127.0.0.1:3773" },
160 "7001": { TCPForward: "127.0.0.2:3773" },
161 "80": { TCPForward: "unix:/run/app.sock" },
162 },
163 Web: {
164 "box.tailnet.ts.net:443": {
165 Handlers: {
166 "/t3": { Proxy: "http://127.0.0.1:3773" },
167 "/dashboard": { Proxy: "http://127.0.0.1:9000" },
168 },
169 },
170 "box.tailnet.ts.net:8443": { Handlers: { "/": { Proxy: "http://127.0.0.1:5173" } } },
171 "box.tailnet.ts.net:10000": {
172 Handlers: {
173 // Another service on another loopback address, and one on the other family.
174 "/a": { Proxy: "http://127.0.0.2:3773" },
175 "/b": { Proxy: "http://[::1]:3773" },
176 "/c": { Proxy: "http://192.168.1.5:3773" },
177 "/d": { Proxy: "http://0.0.0.0:3773" },
178 "/e": { Proxy: "http://[::ffff:127.0.0.1]:3773" },
179 "/f": { Proxy: "http://localhost:3773" },
180 // Shorthand Tailscale expands when it proxies.
181 "/g": { Proxy: "3773" },
182 "/h": { Proxy: "localhost:3773" },
183 "/i": { Proxy: "http://[::]:3773" },
184 // A name can resolve to any address, the bound one included.
185 "/j": { Proxy: "http://ip6-localhost:3773" },
186 // Go resolves these as names; a WHATWG URL rejects the first and rewrites the second.
187 "/k": { Proxy: "http://box.1:3773" },
188 "/l": { Proxy: "http://127.1:3773" },
189 "/m": { Proxy: "unix:/run/app.sock" },
190 // Credentials before the host, and IPv6 written out in full, still dial here.
191 "/n": { Proxy: "http://user:pass@127.0.0.1:3773" },
192 "/o": { Proxy: "http://[0:0:0:0:0:ffff:7f00:1]:3773" },
193 "/p": { Proxy: "http://127.0.0.1:03773" },
194 },
195 },
196 },
197 Services: {
198 "svc:t3": {
199 Web: {
200 "t3.tailnet.ts.net:443": { Handlers: { "/": { Proxy: "http://127.0.0.1:3773" } } },
201 },
202 },
203 },
204 Foreground: {
205 session1: { TCP: { "9000": { TCPForward: "localhost:3773" } } },
206 },
207 });
208 assert.deepEqual(parseTailscaleServeRoutesTo(status, { host: "127.0.0.1", port: 3773 }), [
209 { servePort: 7000 },
210 { servePort: 443, mount: "/t3", host: "box.tailnet.ts.net" },
211 // `localhost` and `[::]` can dial either loopback, so they might be another service's.
212 ...(
213 [
214 ["/d", undefined],
215 ["/e", undefined],
216 ["/f", "localhost"],
217 ["/g", undefined],
218 ["/h", "localhost"],
219 ["/i", "[::]"],
220 ["/j", "ip6-localhost"],
221 ["/k", "box.1"],
222 ["/l", "127.1"],
223 ["/n", undefined],
224 ["/o", undefined],
225 ["/p", undefined],
226 ] as const
227 ).map(([mount, ambiguous]) => ({
228 servePort: 10000,
229 mount,
230 host: "box.tailnet.ts.net",
231 ...(ambiguous === undefined ? {} : { ambiguous }),
232 })),
233 { servePort: 443, mount: "/", host: "t3.tailnet.ts.net", owner: "svc:t3" },
234 { servePort: 9000, ambiguous: "localhost", owner: "foreground" },
235 ]);
236 assert.deepEqual(
237 parseTailscaleServeRoutesTo(
238 JSON.stringify({
239 Web: { "box:443": { Handlers: { "/": { Proxy: "http://127.0.0.1" } } } },
240 }),
241 { host: "127.0.0.1", port: 80 },
242 ),
243 [{ servePort: 443, mount: "/", host: "box" }],
244 );
245 assert.deepEqual(parseTailscaleServeRoutesTo("{}", { host: "127.0.0.1", port: 3773 }), []);
246 assert.deepEqual(parseTailscaleServeRoutesTo("null", { host: "127.0.0.1", port: 3773 }), []);
247 assert.isUndefined(parseTailscaleServeRoutesTo("not json", { host: "127.0.0.1", port: 3773 }));
248 });
249​
250 it("finds what holds a Serve port's root", () => {
251 const target = { host: "127.0.0.1", port: 3773 };
252 const status = JSON.stringify({
253 TCP: { "8443": { TCPForward: "127.0.0.1:22" } },
254 Web: {
255 "box.tailnet.ts.net:443": {
256 Handlers: { "/": { Proxy: "http://127.0.0.1:3773" }, "/x": { Proxy: "9000" } },
257 },
258 "old.tailnet.ts.net:443": { Handlers: { "/": { Proxy: "http://[::1]:4000" } } },
259 "box.tailnet.ts.net:10000": { Handlers: { "/": { Proxy: "http://127.0.0.1:4001" } } },
260 "box.tailnet.ts.net:10001": { Handlers: { "/": { Proxy: "http://10.0.0.5:4002" } } },
261 "box.tailnet.ts.net:10002": { Handlers: { "/": { Text: "hi" } } },
262 },
263 Services: {
264 "svc:t3": {
265 Web: { "t3.tailnet.ts.net:10000": { Handlers: { "/": { Proxy: "4003" } } } },
266 },
267 },
268 });
269 assert.deepEqual(parseTailscaleServeRootHolders(status, 443, target), [
270 { ours: true },
271 { ours: false, loopback: { host: "::1", port: 4000 } },
272 ]);
273 assert.deepEqual(parseTailscaleServeRootHolders(status, 10000, target), [
274 { ours: false, loopback: { host: "127.0.0.1", port: 4001 } },
275 ]);
276 assert.deepEqual(parseTailscaleServeRootHolders(status, 10001, target), [{ ours: false }]);
277 assert.deepEqual(parseTailscaleServeRootHolders(status, 10002, target), [{ ours: false }]);
278 assert.deepEqual(parseTailscaleServeRootHolders(status, 8443, target), [{ ours: false }]);
279 assert.deepEqual(parseTailscaleServeRootHolders("null", 443, target), []);
280 assert.isUndefined(parseTailscaleServeRootHolders("not json", 443, target));
281 });
282​
283 it.effect("parses status facts", () =>
284 Effect.gen(function* () {
285 const status = yield* parseTailscaleStatus(tailscaleStatusJson);
286 assert.deepEqual(status, {
287 running: null,
288 magicDnsName: "desktop.tail.ts.net",
289 tailnetIpv4Addresses: ["100.100.100.100"],
290 httpsServeEnabled: null,
291 });
292 }),
293 );
294​
295 it.effect("names the exit node in use", () =>
296 Effect.gen(function* () {
297 const inUse = yield* parseTailscaleExitNode(
298 JSON.stringify({
299 ExitNodeStatus: { ID: "nX", Online: true, TailscaleIPs: ["100.101.102.103/32"] },
300 Peer: {
301 "nodekey:a": { HostName: "laptop", DNSName: "laptop.tail.ts.net.", ExitNode: false },
302 "nodekey:b": {
303 HostName: "raspberrypi",
304 DNSName: "home-pi.tail.ts.net.",
305 ExitNode: true,
306 },
307 },
308 }),
309 );
310 assert.deepEqual(inUse, { name: "home-pi", online: true });
311​
312 const unnamed = yield* parseTailscaleExitNode(`{"ExitNodeStatus":{"Online":false}}`);
313 assert.deepEqual(unnamed, { name: null, online: false });
314​
315 assert.equal(yield* parseTailscaleExitNode(tailscaleStatusJson), null);
316 }),
317 );
318​
319 it.effect("reads the HTTPS serve capability from the status capability map", () =>
320 Effect.gen(function* () {
321 const enabled = yield* parseTailscaleStatus(
322 `{"Self":{"CapMap":{"https":null,"funnel":null}}}`,
323 );
324 assert.equal(enabled.httpsServeEnabled, true);
325​
326 const disabled = yield* parseTailscaleStatus(`{"Self":{"CapMap":{"funnel":null}}}`);
327 assert.equal(disabled.httpsServeEnabled, false);
328 }),
329 );
330​
331 it.effect("reads Tailscale SSH enablement from local prefs", () => {
332 const prefsJson =
333 '{\n\t"ControlURL": "https://controlplane.tailscale.com",\n\t"RunSSH": true,\n\t"WantRunning": true\n}\n';
334 const layer = layerMockSpawner((command, args) => {
335 assert.equal(command, "tailscale");
336 assert.deepEqual(args, ["debug", "prefs"]);
337 return { stdout: prefsJson };
338 });
339​
340 return Effect.gen(function* () {
341 assert.isTrue(yield* readTailscaleSshEnabled.pipe(Effect.provide(layer)));
342 assert.isFalse(yield* parseTailscaleSshEnabled('{"RunSSH":false,"WantRunning":true}'));
343 assert.isFalse(yield* parseTailscaleSshEnabled('{"RunSSH":true,"WantRunning":false}'));
344 assert.isFalse(yield* parseTailscaleSshEnabled("{}"));
345 const error = yield* parseTailscaleSshEnabled("{not-json").pipe(Effect.flip);
346 assert.instanceOf(error, TailscaleStatusParseError);
347 });
348 });
349​
350 it.effect("turns spawn defects into typed spawn failures", () => {
351 // A non-directory entry on PATH makes node's spawn throw ENOTDIR
352 // synchronously. The platform spawner calls `NodeChildProcess.spawn` from
353 // inside an `Effect.callback` registration, so that throw arrives as a
354 // defect rather than a typed error - the shape reproduced here.
355 const defect = Object.assign(new Error("spawn tailscale ENOTDIR"), { code: "ENOTDIR" });
356 const layer = layerSpawner(
357 ChildProcessSpawner.make(() =>
358 Effect.callback<never, never>(() => {
359 throw defect;
360 }),
361 ),
362 );
363​
364 return Effect.gen(function* () {
365 const statusError = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer));
366 assert.instanceOf(statusError, TailscaleCommandSpawnError);
367 assert.equal(statusError.subcommand, "status");
368 assert.strictEqual(statusError.cause, defect);
369​
370 const serveError = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(
371 Effect.flip,
372 Effect.provide(layer),
373 );
374 assert.instanceOf(serveError, TailscaleCommandSpawnError);
375 assert.equal(serveError.subcommand, "serve");
376 assert.strictEqual(serveError.cause, defect);
377​
378 const degraded = yield* readTailscaleStatus.pipe(
379 Effect.orElseSucceed(() => null),
380 Effect.provide(layer),
381 );
382 assert.equal(degraded, null);
383 });
384 });
385​
386 it.effect("keeps nonzero exit diagnostics structured", () => {
387 const layer = layerMockSpawner(() => ({
388 code: 7,
389 stderr: "not logged in tskey-auth-secret-token-value",
390 }));
391​
392 return Effect.gen(function* () {
393 const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer));
394​
395 assert.instanceOf(error, TailscaleCommandExitError);
396 assert.equal(error.executable, "tailscale");
397 assert.equal(error.subcommand, "status");
398 assert.equal(error.argumentCount, 2);
399 assert.equal(error.exitCode, 7);
400 assert.equal(error.stdoutLength, 0);
401 assert.equal(error.stderrLength, 43);
402 assert.notProperty(error, "command");
403 assert.notProperty(error, "stderr");
404 assert.notInclude(error.message, "tskey-auth-secret-token-value");
405 assert.equal(error.message, "tailscale status exited with code 7.");
406 assert.equal(error.stderrDiagnostic, "not-logged-in");
407 assertCarriesNoSecret(error, "tskey-auth-secret-token-value");
408 });
409 });
410​
411 // regression: #34736 - a full stdout pipe blocks the child before it exits.
412 it.effect("drains serve stdout so a long success summary does not time out", () =>
413 Effect.gen(function* () {
414 const stdoutDrained = yield* Deferred.make<void>();
415 const layer = layerSpawner(
416 ChildProcessSpawner.make(() =>
417 Effect.succeed(
418 ChildProcessSpawner.makeHandle({
419 pid: ChildProcessSpawner.ProcessId(1),
420 exitCode: Deferred.await(stdoutDrained).pipe(
421 Effect.as(ChildProcessSpawner.ExitCode(0)),
422 ),
423 isRunning: Effect.succeed(true),
424 kill: () => Effect.void,
425 unref: Effect.succeed(Effect.void),
426 stdin: Sink.drain,
427 stdout: Stream.make(encoder.encode("https://example.ts.net/\n")).pipe(
428 Stream.ensuring(Deferred.succeed(stdoutDrained, undefined)),
429 ),
430 stderr: Stream.empty,
431 all: Stream.empty,
432 getInputFd: () => Sink.drain,
433 getOutputFd: () => Stream.empty,
434 }),
435 ),
436 ),
437 );
438​
439 const fiber = yield* ensureTailscaleServe({ localPort: 3773 }).pipe(
440 Effect.provide(layer),
441 Effect.exit,
442 Effect.forkChild,
443 );
444 yield* TestClock.adjust("11 seconds");
445 const exit = yield* Fiber.join(fiber);
446 assert.isTrue(Exit.isSuccess(exit), String(exit));
447 }),
448 );
449​
450 // regression: #35510 - without the https capability `tailscale serve` prints an
451 // enablement notice, writes no Serve config, and still exits 0.
452 it.effect("fails serve when the tailnet has not enabled HTTPS", () => {
453 const layer = layerMockSpawner((_command, args) => {
454 if (args[0] === "status") {
455 return {
456 stdout:
457 '{"BackendState":"Running","Self":{"DNSName":"desktop.tail.ts.net.","CapMap":{}}}',
458 };
459 }
460 if (args[1] === "status") return { stdout: "{}\n" };
461 return { stdout: "\nYou don't have permission to enable this feature.\n" };
462 });
463​
464 return Effect.gen(function* () {
465 const exit = yield* ensureTailscaleServe({ localPort: 3773 }).pipe(
466 Effect.provide(layer),
467 Effect.exit,
468 );
469 assert.isTrue(Exit.isFailure(exit), "no Serve route was configured");
470 });
471 });
472​
473 // regression: #21609 - without forceKillAfter a timed-out probe only gets
474 // SIGTERM, so a group member that ignores it outlives the call.
475 it.effect("escalates timed-out probes to SIGKILL", () => {
476 const killGraces: Array<number | undefined> = [];
477 const layer = layerSpawner(
478 ChildProcessSpawner.make((command) => {
479 if (command._tag === "StandardCommand") {
480 const { forceKillAfter } = command.options;
481 killGraces.push(
482 forceKillAfter === undefined
483 ? undefined
484 : Duration.toMillis(Duration.fromInputUnsafe(forceKillAfter)),
485 );
486 }
487 return Effect.succeed(mockHandle({ stdout: "{}" }));
488 }),
489 );
490​
491 return Effect.gen(function* () {
492 yield* readTailscaleStatus.pipe(Effect.provide(layer));
493 yield* readTailscaleSshEnabled.pipe(Effect.provide(layer));
494 yield* ensureTailscaleServe({ localPort: 45_678 }).pipe(Effect.provide(layer));
495 assert.deepEqual(killGraces, [500, 500, 500, 500]);
496 });
497 });
498});