1import * as HostProcess from "@t3tools/shared/HostProcess";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6import * as Stream from "effect/Stream";
7import { HttpClient, HttpClientRequest } from "effect/http";
8import { ChildProcess, ChildProcessSpawner } from "effect/process";
9
10export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
11export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500);
12const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10);
13const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500);
14
15// tailscale is a real executable everywhere (`tailscale.exe` on Windows), so
16// it is always spawned directly rather than through cmd.exe shell mode.
17const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" =>
18 platform === "win32" ? "tailscale.exe" : "tailscale";
19
20const TailscaleCommandContext = {
21 executable: Schema.Literals(["tailscale", "tailscale.exe"]),
22 subcommand: Schema.Literals(["status", "serve", "debug"]),
23 argumentCount: Schema.Number,
24};
25
26/**
27 * Failure kinds we can name without quoting the CLI. Anything unrecognized
28 * becomes "unknown" rather than falling back to raw text — stderr can contain
29 * auth keys (`tskey-…`) and node names, and these labels are logged.
30 */
31export const TailscaleStderrDiagnostic = Schema.Literals([
32 "no-existing-handler",
33 "not-logged-in",
34 "permission-denied",
35 "unknown",
36]);
37export type TailscaleStderrDiagnostic = typeof TailscaleStderrDiagnostic.Type;
38
39// Matched against stderr, most specific first. Patterns are deliberately short
40// and anchored on tailscale's own wording.
41const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray<
42 readonly [RegExp, Exclude<TailscaleStderrDiagnostic, "unknown">]
43> = [
44 [/handler does not exist/i, "no-existing-handler"],
45 [/not logged in|logged out|needs? login/i, "not-logged-in"],
46 [/permission denied|access denied|must be root|operation not permitted/i, "permission-denied"],
47];
48
49/** Classifies stderr into a safe label, dropping the text itself. */
50const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => {
51 if (stderr.trim().length === 0) {
52 return undefined;
53 }
54 return STDERR_DIAGNOSTIC_PATTERNS.find(([pattern]) => pattern.test(stderr))?.[1] ?? "unknown";
55};
56
57export class TailscaleCommandSpawnError extends Schema.TaggedError<TailscaleCommandSpawnError>()(
58 "TailscaleCommandSpawnError",
59 {
60 ...TailscaleCommandContext,
61 cause: Schema.Defect(),
62 },
63) {
64 override get message(): string {
65 return `Failed to spawn tailscale ${this.subcommand}.`;
66 }
67}
68
69class TailscaleCommandOutputError extends Schema.TaggedError<TailscaleCommandOutputError>()(
70 "TailscaleCommandOutputError",
71 {
72 ...TailscaleCommandContext,
73 cause: Schema.Defect(),
74 },
75) {
76 override get message(): string {
77 return `Failed to read output from tailscale ${this.subcommand}.`;
78 }
79}
80
81export class TailscaleCommandExitError extends Schema.TaggedError<TailscaleCommandExitError>()(
82 "TailscaleCommandExitError",
83 {
84 ...TailscaleCommandContext,
85 exitCode: Schema.Number,
86 stdoutLength: Schema.optional(Schema.Number),
87 stderrLength: Schema.Number,
88 // A classified diagnostic, never raw CLI output. `tailscale` prints auth
89 // keys and node identifiers into stderr, and this field is surfaced in
90 // dev-runner logs — so it carries only a known-safe label from the closed
91 // set below. Callers that need to recognize a specific failure (e.g.
92 // `serve off` on a port with no mapping) match on the label.
93 stderrDiagnostic: Schema.optional(TailscaleStderrDiagnostic),
94 },
95) {
96 override get message(): string {
97 return `tailscale ${this.subcommand} exited with code ${this.exitCode}.`;
98 }
99}
100
101export class TailscaleCommandTimeoutError extends Schema.TaggedError<TailscaleCommandTimeoutError>()(
102 "TailscaleCommandTimeoutError",
103 {
104 ...TailscaleCommandContext,
105 timeoutMs: Schema.Number,
106 cause: Schema.Defect(),
107 },
108) {
109 override get message(): string {
110 return `tailscale ${this.subcommand} timed out after ${this.timeoutMs}ms.`;
111 }
112}
113
114export type TailscaleCommandError =
115 | TailscaleCommandSpawnError
116 | TailscaleCommandOutputError
117 | TailscaleCommandExitError
118 | TailscaleCommandTimeoutError;
119
120export class TailscaleStatusParseError extends Schema.TaggedError<TailscaleStatusParseError>()(
121 "TailscaleStatusParseError",
122 { cause: Schema.Defect() },
123) {
124 override get message(): string {
125 return "Failed to decode tailscale status JSON.";
126 }
127}
128
129const TailscaleStatusSelf = Schema.Struct({
130 DNSName: Schema.optional(Schema.Unknown),
131 TailscaleIPs: Schema.optional(Schema.Unknown),
132});
133
134const TailscaleStatusJson = Schema.Struct({
135 Self: Schema.optional(TailscaleStatusSelf),
136});
137
138export type TailscaleStatusJson = typeof TailscaleStatusJson.Type;
139
140export interface TailscaleStatus {
141 readonly magicDnsName: string | null;
142 readonly tailnetIpv4Addresses: readonly string[];
143}
144
145const collectStdout = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
146 stream.pipe(
147 Stream.decodeText(),
148 Stream.runFold(
149 () => "",
150 (acc, chunk) => acc + chunk,
151 ),
152 );
153
154const collectStderr = collectStdout;
155
156const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson));
157
158function normalizeMagicDnsName(status: TailscaleStatusJson): string | null {
159 const dnsName = status.Self?.DNSName;
160 if (typeof dnsName !== "string") {
161 return null;
162 }
163
164 const normalized = dnsName.trim().replace(/\.$/u, "");
165 return normalized.length > 0 ? normalized : null;
166}
167
168export const parseTailscaleMagicDnsName = (
169 rawStatusJson: string,
170): Effect.Effect<string | null, TailscaleStatusParseError> =>
171 decodeTailscaleStatusJson(rawStatusJson).pipe(
172 Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),
173 Effect.map(normalizeMagicDnsName),
174 );
175
176export function isTailscaleIpv4Address(address: string): boolean {
177 const parts = address.split(".");
178 if (parts.length !== 4) {
179 return false;
180 }
181 const [first, second, third, fourth] = parts.map((part) => Number.parseInt(part, 10));
182 if (
183 first === undefined ||
184 second === undefined ||
185 third === undefined ||
186 fourth === undefined ||
187 [first, second, third, fourth].some((part) => !Number.isInteger(part) || part < 0 || part > 255)
188 ) {
189 return false;
190 }
191 return first === 100 && second >= 64 && second <= 127;
192}
193
194export const parseTailscaleStatus = (
195 rawStatusJson: string,
196): Effect.Effect<TailscaleStatus, TailscaleStatusParseError> =>
197 decodeTailscaleStatusJson(rawStatusJson).pipe(
198 Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),
199 Effect.map((parsed) => {
200 const rawIps = parsed.Self?.TailscaleIPs;
201 const tailnetIpv4Addresses: Array<string> = [];
202 if (Array.isArray(rawIps)) {
203 for (const address of rawIps) {
204 if (typeof address === "string" && isTailscaleIpv4Address(address)) {
205 tailnetIpv4Addresses.push(address);
206 }
207 }
208 }
209
210 return {
211 magicDnsName: normalizeMagicDnsName(parsed),
212 tailnetIpv4Addresses,
213 };
214 }),
215 );
216
217/** Runs a read-only tailscale command and returns its stdout. */
218const 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 })),
236 ),
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
276export const readTailscaleStatus = readTailscaleOutput("status", ["status", "--json"]).pipe(
277 Effect.flatMap(parseTailscaleStatus),
278);
279
280const TailscalePrefsJson = Schema.Struct({
281 RunSSH: Schema.optional(Schema.Unknown),
282 WantRunning: Schema.optional(Schema.Unknown),
283});
284
285const 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 */
292export 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
300export const readTailscaleSshEnabled = readTailscaleOutput("debug", ["debug", "prefs"]).pipe(
301 Effect.flatMap(parseTailscaleSshEnabled),
302);
303
304export function buildTailscaleHttpsBaseUrl(input: {
305 readonly magicDnsName: string;
306 readonly servePort?: number;
307}): string {
308 const url = new URL(`https://${input.magicDnsName}`);
309 const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT;
310 if (servePort !== DEFAULT_TAILSCALE_SERVE_PORT) {
311 url.port = String(servePort);
312 }
313 url.pathname = "/";
314 return url.toString();
315}
316
317const runTailscaleCommand = (
318 args: readonly string[],
319 timeoutInput: Duration.Input,
320): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>
321 Effect.gen(function* () {
322 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
323 const hostPlatform = yield* HostProcess.Platform;
324 const executable = tailscaleCommandForPlatform(hostPlatform);
325 const commandContext = {
326 executable,
327 subcommand: "serve" as const,
328 argumentCount: args.length,
329 };
330 const timeout = Duration.fromInputUnsafe(timeoutInput);
331 return yield* Effect.gen(function* () {
332 const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe(
333 Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
334 Effect.catchDefect((cause) =>
335 Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })),
336 ),
337 );
338 const [stderr, exitCode] = yield* Effect.all(
339 [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))],
340 { concurrency: "unbounded" },
341 ).pipe(
342 Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })),
343 );
344 if (exitCode !== 0) {
345 return yield* new TailscaleCommandExitError({
346 ...commandContext,
347 exitCode,
348 stderrLength: stderr.length,
349 ...(stderrDiagnosticOf(stderr) !== undefined
350 ? { stderrDiagnostic: stderrDiagnosticOf(stderr) }
351 : {}),
352 });
353 }
354 }).pipe(
355 Effect.scoped,
356 Effect.timeout(timeout),
357 Effect.catchTags({
358 TimeoutError: (cause) =>
359 Effect.fail(
360 new TailscaleCommandTimeoutError({
361 ...commandContext,
362 timeoutMs: Duration.toMillis(timeout),
363 cause,
364 }),
365 ),
366 }),
367 );
368 });
369
370export const ensureTailscaleServe = (input: {
371 readonly localPort: number;
372 readonly servePort?: number;
373 readonly localHost?: string;
374}): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> => {
375 const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT;
376 const localHost = input.localHost ?? "127.0.0.1";
377 const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`];
378 return runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT);
379};
380
381export const disableTailscaleServe = (
382 input: {
383 readonly servePort?: number;
384 } = {},
385): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>
386 Effect.gen(function* () {
387 const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT;
388 return yield* runTailscaleCommand(
389 ["serve", `--https=${servePort}`, "off"],
390 TAILSCALE_SERVE_TIMEOUT,
391 );
392 });
393
394export const probeTailscaleHttpsEndpoint = (input: {
395 readonly baseUrl: string;
396 readonly timeout?: Duration.Input;
397}): Effect.Effect<boolean, never, HttpClient.HttpClient> =>
398 Effect.gen(function* () {
399 const client = yield* HttpClient.HttpClient;
400 const response = yield* Effect.gen(function* () {
401 const url = new URL("/.well-known/t3/environment", input.baseUrl);
402 const request = HttpClientRequest.get(url.toString());
403 return yield* client.execute(request);
404 }).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT));
405
406 return Option.match(response, {
407 onNone: () => false,
408 onSome: (httpResponse) => httpResponse.status >= 200 && httpResponse.status < 300,
409 });
410 }).pipe(Effect.orElseSucceed(() => false));