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/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500); export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); export class TailscaleCommandError extends Schema.TaggedErrorClass()( "TailscaleCommandError", { command: Schema.Array(Schema.String), message: Schema.String, exitCode: Schema.NullOr(Schema.Number), stderr: Schema.String, }, ) {} export class TailscaleStatusParseError extends Schema.TaggedErrorClass()( "TailscaleStatusParseError", { cause: Schema.Defect, }, ) {} export class TailscaleUnavailableError extends Schema.TaggedErrorClass()( "TailscaleUnavailableError", { reason: Schema.String, }, ) {} const TailscaleStatusSelf = Schema.Struct({ DNSName: Schema.optional(Schema.Unknown), TailscaleIPs: Schema.optional(Schema.Unknown), }); const TailscaleStatusJson = Schema.Struct({ Self: Schema.optional(TailscaleStatusSelf), }); export type TailscaleStatusSelf = typeof TailscaleStatusSelf.Type; export type TailscaleStatusJson = typeof TailscaleStatusJson.Type; export interface TailscaleStatus { readonly magicDnsName: Option.Option; readonly tailnetIpv4Addresses: readonly string[]; } const collectStdout = (stream: Stream.Stream): Effect.Effect => stream.pipe( Stream.decodeText(), Stream.runFold( () => "", (acc, chunk) => acc + chunk, ), ); const collectStderr = collectStdout; const tailscaleCommandError = ( args: readonly string[], message: string, exitCode: number | null, stderr = "", ): TailscaleCommandError => new TailscaleCommandError({ command: ["tailscale", ...args], message, exitCode, stderr, }); const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson)); function normalizeMagicDnsName(status: TailscaleStatusJson): Option.Option { const dnsName = status.Self?.DNSName; if (typeof dnsName !== "string") { return Option.none(); } const normalized = dnsName.trim().replace(/\.$/u, ""); return normalized.length > 0 ? Option.some(normalized) : Option.none(); } export const parseTailscaleMagicDnsName = ( rawStatusJson: string, ): Effect.Effect, TailscaleStatusParseError> => decodeTailscaleStatusJson(rawStatusJson).pipe( Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), Effect.map(normalizeMagicDnsName), ); 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.isArray(rawIps) ? rawIps .filter((address): address is string => typeof address === "string") .filter(isTailscaleIpv4Address) : []; return { magicDnsName: normalizeMagicDnsName(parsed), tailnetIpv4Addresses, }; }), ); export const readTailscaleStatus: Effect.Effect< TailscaleStatus, TailscaleCommandError | TailscaleStatusParseError, ChildProcessSpawner.ChildProcessSpawner > = Effect.gen(function* () { const args = ["status", "--json"]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner .spawn( ChildProcess.make("tailscale", args, { shell: process.platform === "win32", }), ) .pipe( Effect.mapError((cause) => tailscaleCommandError( args, cause instanceof Error ? cause.message : "Failed to spawn tailscale status.", null, ), ), ); 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) => tailscaleCommandError( args, cause instanceof Error ? cause.message : "Failed to run tailscale status.", null, ), ), ); if (exitCode !== 0) { return yield* tailscaleCommandError( args, `Tailscale status exited with code ${exitCode}.`, exitCode, stderr, ); } return yield* parseTailscaleStatus(stdout); }).pipe( Effect.scoped, Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT), Effect.flatMap((result) => Option.match(result, { onNone: () => Effect.fail( tailscaleCommandError(["status", "--json"], "Tailscale status timed out.", null), ), onSome: Effect.succeed, }), ), ); 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 = ( args: readonly string[], input: { readonly spawnMessage: string; readonly runMessage: string; readonly exitMessage: (exitCode: number) => string; readonly timeoutMessage: string; readonly timeout: Duration.Duration; }, ): Effect.Effect => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner .spawn( ChildProcess.make("tailscale", args, { shell: process.platform === "win32", }), ) .pipe( Effect.mapError((cause) => tailscaleCommandError( args, cause instanceof Error ? cause.message : input.spawnMessage, null, ), ), ); const [stderr, exitCode] = yield* Effect.all( [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], { concurrency: "unbounded" }, ).pipe( Effect.mapError((cause) => tailscaleCommandError( args, cause instanceof Error ? cause.message : input.runMessage, null, ), ), ); if (exitCode !== 0) { return yield* tailscaleCommandError(args, input.exitMessage(exitCode), exitCode, stderr); } }).pipe( Effect.scoped, Effect.timeoutOption(input.timeout), Effect.flatMap((result) => Option.match(result, { onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)), onSome: Effect.succeed, }), ), ); 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, { spawnMessage: "Failed to spawn tailscale serve.", runMessage: "Failed to run tailscale serve.", exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`, timeoutMessage: "Tailscale serve timed out.", timeout: TAILSCALE_SERVE_TIMEOUT, }); }; export const disableTailscaleServe = ( input: { readonly servePort?: number; } = {}, ): Effect.Effect => Effect.gen(function* () { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; return yield* runTailscaleCommand(["serve", `--https=${servePort}`, "off"], { spawnMessage: "Failed to spawn tailscale serve off.", runMessage: "Failed to run tailscale serve off.", exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`, timeoutMessage: "Tailscale serve off timed out.", timeout: TAILSCALE_SERVE_TIMEOUT, }); }); export const probeTailscaleHttpsEndpoint = (input: { readonly baseUrl: string; readonly timeout?: Duration.Duration; }): Effect.Effect => Effect.gen(function* () { 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, }); }).pipe(Effect.catch(() => Effect.succeed(false))); export const resolveTailscaleHttpsBaseUrl = ( input: { readonly servePort?: number; } = {}, ): Effect.Effect< Option.Option, TailscaleCommandError | TailscaleStatusParseError, ChildProcessSpawner.ChildProcessSpawner > => readTailscaleStatus.pipe( Effect.map((status) => Option.map(status.magicDnsName, (magicDnsName) => buildTailscaleHttpsBaseUrl({ magicDnsName, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), }), ), ), );