327 lines · 10.5 KB · TypeScript
RawDownload
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";import * as Data from "effect/Data";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_MS = 1_500;export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000;export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500; // tailscale is a real executable everywhere (`tailscale.exe` on Windows), so// it is always spawned directly rather than through cmd.exe shell mode.const tailscaleCommandForPlatform = (platform: NodeJS.Platform): string =>  platform === "win32" ? "tailscale.exe" : "tailscale"; export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{  readonly command: readonly string[];  readonly message: string;  readonly exitCode: number | null;  readonly stderr: string;}> {} export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{  readonly cause: unknown;}> {} export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{  readonly reason: 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: string | null;  readonly tailnetIpv4Addresses: readonly string[];} const collectStdout = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>  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): 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 const parseTailscaleMagicDnsName = (  rawStatusJson: string,): Effect.Effect<string | null, 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<TailscaleStatus, TailscaleStatusParseError> =>  decodeTailscaleStatusJson(rawStatusJson).pipe(    Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),    Effect.map((parsed) => {      const rawIps = parsed.Self?.TailscaleIPs;      const tailnetIpv4Addresses: Array<string> = [];      if (Array.isArray(rawIps)) {        for (const address of rawIps) {          if (typeof address === "string" && isTailscaleIpv4Address(address)) {            tailnetIpv4Addresses.push(address);          }        }      }       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 hostPlatform = yield* HostProcessPlatform;  const child = yield* spawner    .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args))    .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_MS),  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 timeoutMs: number;  },): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>  Effect.gen(function* () {    const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;    const hostPlatform = yield* HostProcessPlatform;    const child = yield* spawner      .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args))      .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.timeoutMs),    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<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> => {  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.",    timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,  });}; export const disableTailscaleServe = (  input: {    readonly servePort?: number;  } = {},): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>  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.",      timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,    });  }); export const probeTailscaleHttpsEndpoint = (input: {  readonly baseUrl: string;  readonly timeoutMs?: number;}): Effect.Effect<boolean, never, HttpClient.HttpClient> =>  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.timeoutMs ?? TAILSCALE_PROBE_TIMEOUT_MS));     return Option.match(response, {      onNone: () => false,      onSome: (httpResponse) => httpResponse.status >= 200 && httpResponse.status < 300,    });  }).pipe(Effect.orElseSucceed(() => false)); export const resolveTailscaleHttpsBaseUrl = (  input: {    readonly servePort?: number;  } = {},): Effect.Effect<  string | null,  TailscaleCommandError | TailscaleStatusParseError,  ChildProcessSpawner.ChildProcessSpawner> =>  readTailscaleStatus.pipe(    Effect.map((status) =>      status.magicDnsName        ? buildTailscaleHttpsBaseUrl({            magicDnsName: status.magicDnsName,            ...(input.servePort === undefined ? {} : { servePort: input.servePort }),          })        : null,    ),  );