import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; /** * Bounds `tailscale status` and `tailscale debug prefs`. Allows the 1.8-2.6 s * `tailscale status` has been measured at when T3 Code spawns it on Windows. * A CLI that never answers takes longer than this to fail, because the kill * that follows the timeout also takes time (`taskkill` on Windows). */ const TAILSCALE_STATUS_TIMEOUT = Duration.millis(3_500); /** * How long a probe killed on timeout gets to exit after SIGTERM before its * process group is SIGKILLed. Without it the spawner never escalates, so a group * member that ignores SIGTERM outlives the call. Short because the timeout error * only surfaces once cleanup finishes, and a healthy `tailscale` exits at once. */ const TAILSCALE_KILL_GRACE = Duration.millis(500); const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" => platform === "win32" ? "tailscale.exe" : "tailscale"; const TailscaleCommandContext = { executable: Schema.Literals(["tailscale", "tailscale.exe"]), subcommand: Schema.Literals(["status", "serve", "debug"]), argumentCount: Schema.Number, }; /** * Failure kinds we can name without quoting the CLI. Anything unrecognized * becomes "unknown" rather than falling back to raw text — stderr can contain * auth keys (`tskey-…`) and node names, and these labels are logged. */ export const TailscaleStderrDiagnostic = Schema.Literals([ "no-existing-handler", "not-logged-in", "permission-denied", "unknown", ]); export type TailscaleStderrDiagnostic = typeof TailscaleStderrDiagnostic.Type; const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray< readonly [RegExp, Exclude] > = [ [/handler does not exist/i, "no-existing-handler"], [/not logged in|logged out|needs? login/i, "not-logged-in"], [/permission denied|access denied|must be root|operation not permitted/i, "permission-denied"], ]; const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => { if (stderr.trim().length === 0) { return undefined; } return STDERR_DIAGNOSTIC_PATTERNS.find(([pattern]) => pattern.test(stderr))?.[1] ?? "unknown"; }; export class TailscaleCommandSpawnError extends Schema.TaggedError()( "TailscaleCommandSpawnError", { ...TailscaleCommandContext, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to spawn tailscale ${this.subcommand}.`; } } class TailscaleCommandOutputError extends Schema.TaggedError()( "TailscaleCommandOutputError", { ...TailscaleCommandContext, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to read output from tailscale ${this.subcommand}.`; } } export class TailscaleCommandExitError extends Schema.TaggedError()( "TailscaleCommandExitError", { ...TailscaleCommandContext, exitCode: Schema.Number, stdoutLength: Schema.optional(Schema.Number), stderrLength: Schema.Number, stderrDiagnostic: Schema.optional(TailscaleStderrDiagnostic), }, ) { override get message(): string { return `tailscale ${this.subcommand} exited with code ${this.exitCode}.`; } } export class TailscaleCommandTimeoutError extends Schema.TaggedError()( "TailscaleCommandTimeoutError", { ...TailscaleCommandContext, timeoutMs: Schema.Number, cause: Schema.Defect(), }, ) { override get message(): string { return `tailscale ${this.subcommand} timed out after ${this.timeoutMs}ms.`; } } export type TailscaleCommandError = | TailscaleCommandSpawnError | TailscaleCommandOutputError | TailscaleCommandExitError | TailscaleCommandTimeoutError; export class TailscaleStatusParseError extends Schema.TaggedError()( "TailscaleStatusParseError", { cause: Schema.Defect() }, ) { override get message(): string { return "Failed to decode tailscale status JSON."; } } const TailscaleStatusSelf = Schema.Struct({ DNSName: Schema.optional(Schema.Unknown), TailscaleIPs: Schema.optional(Schema.Unknown), CapMap: Schema.optional(Schema.Unknown), }); const TailscaleStatusJson = Schema.Struct({ Self: Schema.optional(TailscaleStatusSelf), }); export type TailscaleStatusJson = typeof TailscaleStatusJson.Type; export interface TailscaleStatus { readonly magicDnsName: string | null; readonly tailnetIpv4Addresses: readonly string[]; /** * `tailscale serve --https` needs the `https` capability, which the control * plane grants once the tailnet enables HTTPS certificates. `null` when the * CLI reports no capability map (older CLI), so callers still attempt serve. */ readonly httpsServeEnabled: boolean | null; } const collectStdout = (stream: Stream.Stream): Effect.Effect => stream.pipe( Stream.decodeText(), Stream.runFold( () => "", (acc, chunk) => acc + chunk, ), ); const collectStderr = collectStdout; const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson)); function normalizeMagicDnsName(status: TailscaleStatusJson): string | null { const dnsName = status.Self?.DNSName; if (typeof dnsName !== "string") { return null; } const normalized = dnsName.trim().replace(/\.$/u, ""); return normalized.length > 0 ? normalized : null; } export function isTailscaleIpv4Address(address: string): boolean { const parts = address.split("."); if (parts.length !== 4) { return false; } const [first, second, third, fourth] = parts.map((part) => Number.parseInt(part, 10)); if ( first === undefined || second === undefined || third === undefined || fourth === undefined || [first, second, third, fourth].some((part) => !Number.isInteger(part) || part < 0 || part > 255) ) { return false; } return first === 100 && second >= 64 && second <= 127; } export const parseTailscaleStatus = ( rawStatusJson: string, ): Effect.Effect => decodeTailscaleStatusJson(rawStatusJson).pipe( Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), Effect.map((parsed) => { const rawIps = parsed.Self?.TailscaleIPs; const tailnetIpv4Addresses: Array = []; if (Array.isArray(rawIps)) { for (const address of rawIps) { if (typeof address === "string" && isTailscaleIpv4Address(address)) { tailnetIpv4Addresses.push(address); } } } const capMap = parsed.Self?.CapMap; return { magicDnsName: normalizeMagicDnsName(parsed), tailnetIpv4Addresses, httpsServeEnabled: typeof capMap === "object" && capMap !== null && !Array.isArray(capMap) ? Object.hasOwn(capMap, "https") : null, }; }), ); const readTailscaleOutput = Effect.fn("tailscale.readTailscaleOutput")(function* ( subcommand: "status" | "debug", args: readonly string[], ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcess.Platform; const executable = tailscaleCommandForPlatform(hostPlatform); const commandContext = { executable, subcommand, argumentCount: args.length, }; const command = ChildProcess.make(executable, [...args], { forceKillAfter: TAILSCALE_KILL_GRACE, }); return yield* Effect.gen(function* () { const child = yield* spawner.spawn(command).pipe( Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), // Spawning can also fail as a defect rather than a typed error - a // non-directory entry on PATH makes node throw ENOTDIR synchronously. // `mapError` never sees that, so it would escape as an uncaught error. Effect.catchDefect((cause) => Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), ), ); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectStdout(child.stdout), collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number)), ], { concurrency: "unbounded" }, ).pipe( Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), ); if (exitCode !== 0) { return yield* new TailscaleCommandExitError({ ...commandContext, exitCode, stdoutLength: stdout.length, stderrLength: stderr.length, ...(stderrDiagnosticOf(stderr) !== undefined ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } : {}), }); } return stdout; }).pipe( Effect.scoped, Effect.timeout(TAILSCALE_STATUS_TIMEOUT), Effect.catchTags({ TimeoutError: (cause) => Effect.fail( new TailscaleCommandTimeoutError({ ...commandContext, timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), cause, }), ), }), ); }); export const readTailscaleStatus = readTailscaleOutput("status", ["status", "--json"]).pipe( Effect.flatMap(parseTailscaleStatus), ); const TailscalePeersJson = Schema.Struct({ BackendState: Schema.optional(Schema.Unknown), TUN: Schema.optional(Schema.Unknown), Self: Schema.optional(Schema.Struct({ TailscaleIPs: Schema.optional(Schema.Unknown) })), ExitNodeStatus: Schema.optional(Schema.Struct({ Online: Schema.optional(Schema.Unknown) })), Peer: Schema.optional( Schema.Record( Schema.String, Schema.Struct({ ExitNode: Schema.optional(Schema.Unknown), HostName: Schema.optional(Schema.Unknown), DNSName: Schema.optional(Schema.Unknown), TailscaleIPs: Schema.optional(Schema.Unknown), }), ), ), }); const decodeTailscalePeersJson = Schema.decodeEffect(Schema.fromJsonString(TailscalePeersJson)); export interface TailscaleExitNode { readonly name: string | null; readonly online: boolean; } /** * `ExitNodeStatus` is present only while an exit node is in use, but it has no * name. The name comes from the peer marked `ExitNode: true`. */ export const parseTailscaleExitNode = ( rawStatusJson: string, ): Effect.Effect => decodeTailscalePeersJson(rawStatusJson).pipe( Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), Effect.map((parsed) => { if (parsed.ExitNodeStatus === undefined) return null; const peer = Object.values(parsed.Peer ?? {}).find( (candidate) => candidate.ExitNode === true, ); // The DNS label is the tailnet machine name; HostName is the OS name and ignores renames. const dnsLabel = typeof peer?.DNSName === "string" ? (peer.DNSName.split(".")[0] ?? "") : ""; const hostName = typeof peer?.HostName === "string" ? peer.HostName.trim() : ""; return { name: dnsLabel || hostName || null, online: parsed.ExitNodeStatus.Online === true, }; }), ); export const readTailscaleExitNode = readTailscaleOutput("status", ["status", "--json"]).pipe( Effect.flatMap(parseTailscaleExitNode), ); /** * The addresses of this machine and every other machine on its tailnet. Empty * unless Tailscale runs with its TUN device: stopped or in userspace networking, * it still lists them, but the OS would send traffic to them unencrypted. */ export const readTailnetAddresses = readTailscaleOutput("status", ["status", "--json"]).pipe( Effect.flatMap((rawStatusJson) => decodeTailscalePeersJson(rawStatusJson).pipe( Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), ), ), Effect.map( (parsed) => new Set( (parsed.BackendState === "Running" && parsed.TUN === true ? [ ...(parsed.Self === undefined ? [] : [parsed.Self]), ...Object.values(parsed.Peer ?? {}), ] : [] ).flatMap((node) => Array.isArray(node.TailscaleIPs) ? node.TailscaleIPs.filter((address): address is string => typeof address === "string") : [], ), ), ), ); const TailscalePrefsJson = Schema.Struct({ RunSSH: Schema.optional(Schema.Unknown), WantRunning: Schema.optional(Schema.Unknown), }); const decodeTailscalePrefsJson = Schema.decodeEffect(Schema.fromJsonString(TailscalePrefsJson)); /** * `tailscale status --json` does not report this for the local node. `tailscale * down` clears WantRunning but keeps RunSSH, so check both. */ export const parseTailscaleSshEnabled = ( rawPrefsJson: string, ): Effect.Effect => decodeTailscalePrefsJson(rawPrefsJson).pipe( Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), Effect.map((prefs) => prefs.RunSSH === true && prefs.WantRunning !== false), ); export const readTailscaleSshEnabled = readTailscaleOutput("debug", ["debug", "prefs"]).pipe( Effect.flatMap(parseTailscaleSshEnabled), ); export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; readonly servePort?: number; }): string { const url = new URL(`https://${input.magicDnsName}`); const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; if (servePort !== DEFAULT_TAILSCALE_SERVE_PORT) { url.port = String(servePort); } url.pathname = "/"; return url.toString(); } const runTailscaleCommand = Effect.fn("tailscale.runTailscaleCommand")(function* ( args: readonly string[], timeoutInput: Duration.Input, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcess.Platform; const executable = tailscaleCommandForPlatform(hostPlatform); const commandContext = { executable, subcommand: "serve" as const, argumentCount: args.length, }; const timeout = Duration.fromInputUnsafe(timeoutInput); return yield* Effect.gen(function* () { const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), Effect.catchDefect((cause) => Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), ), ); const [stderr, exitCode] = yield* Effect.all( [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], { concurrency: "unbounded" }, ).pipe( Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), ); if (exitCode !== 0) { return yield* new TailscaleCommandExitError({ ...commandContext, exitCode, stderrLength: stderr.length, ...(stderrDiagnosticOf(stderr) !== undefined ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } : {}), }); } }).pipe( Effect.scoped, Effect.timeout(timeout), Effect.catchTags({ TimeoutError: (cause) => Effect.fail( new TailscaleCommandTimeoutError({ ...commandContext, timeoutMs: Duration.toMillis(timeout), cause, }), ), }), ); }); export const ensureTailscaleServe = (input: { readonly localPort: number; readonly servePort?: number; readonly localHost?: string; }): Effect.Effect => { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; const localHost = input.localHost ?? "127.0.0.1"; const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`]; return runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT); }; export const disableTailscaleServe = Effect.fn("tailscale.disableTailscaleServe")(function* ( input: { readonly servePort?: number; } = {}, ): Effect.fn.Return { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; return yield* runTailscaleCommand( ["serve", `--https=${servePort}`, "off"], TAILSCALE_SERVE_TIMEOUT, ); }); export const probeTailscaleHttpsEndpoint = Effect.fn("tailscale.probeTailscaleHttpsEndpoint")( function* (input: { readonly baseUrl: string; readonly timeout?: Duration.Input }) { const client = yield* HttpClient.HttpClient; const response = yield* Effect.gen(function* () { const url = new URL("/.well-known/t3/environment", input.baseUrl); const request = HttpClientRequest.get(url.toString()); return yield* client.execute(request); }).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT)); return Option.match(response, { onNone: () => false, onSome: (httpResponse) => httpResponse.status >= 200 && httpResponse.status < 300, }); }, Effect.orElseSucceed(() => false), );