| 1 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | import { HttpClient, HttpClientRequest } from "effect/http"; |
| 8 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 9 | |
| 10 | export const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 11 | export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500); |
| 12 | const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); |
| 13 | const 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. |
| 17 | const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" => |
| 18 | platform === "win32" ? "tailscale.exe" : "tailscale"; |
| 19 | |
| 20 | const 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 | */ |
| 31 | export const TailscaleStderrDiagnostic = Schema.Literals([ |
| 32 | "no-existing-handler", |
| 33 | "not-logged-in", |
| 34 | "permission-denied", |
| 35 | "unknown", |
| 36 | ]); |
| 37 | export 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. |
| 41 | const 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. */ |
| 50 | const 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 | |
| 57 | export 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 | |
| 69 | class 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 | |
| 81 | export 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 | |
| 101 | export 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 | |
| 114 | export type TailscaleCommandError = |
| 115 | | TailscaleCommandSpawnError |
| 116 | | TailscaleCommandOutputError |
| 117 | | TailscaleCommandExitError |
| 118 | | TailscaleCommandTimeoutError; |
| 119 | |
| 120 | export 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 | |
| 129 | const TailscaleStatusSelf = Schema.Struct({ |
| 130 | DNSName: Schema.optional(Schema.Unknown), |
| 131 | TailscaleIPs: Schema.optional(Schema.Unknown), |
| 132 | }); |
| 133 | |
| 134 | const TailscaleStatusJson = Schema.Struct({ |
| 135 | Self: Schema.optional(TailscaleStatusSelf), |
| 136 | }); |
| 137 | |
| 138 | export type TailscaleStatusJson = typeof TailscaleStatusJson.Type; |
| 139 | |
| 140 | export interface TailscaleStatus { |
| 141 | readonly magicDnsName: string | null; |
| 142 | readonly tailnetIpv4Addresses: readonly string[]; |
| 143 | } |
| 144 | |
| 145 | const 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 | |
| 154 | const collectStderr = collectStdout; |
| 155 | |
| 156 | const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson)); |
| 157 | |
| 158 | function 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 | |
| 168 | export 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 | |
| 176 | export 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 | |
| 194 | export 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. */ |
| 218 | const 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 | |
| 276 | export const readTailscaleStatus = readTailscaleOutput("status", ["status", "--json"]).pipe( |
| 277 | Effect.flatMap(parseTailscaleStatus), |
| 278 | ); |
| 279 | |
| 280 | const TailscalePrefsJson = Schema.Struct({ |
| 281 | RunSSH: Schema.optional(Schema.Unknown), |
| 282 | WantRunning: Schema.optional(Schema.Unknown), |
| 283 | }); |
| 284 | |
| 285 | const 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 | */ |
| 292 | export 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 | |
| 300 | export const readTailscaleSshEnabled = readTailscaleOutput("debug", ["debug", "prefs"]).pipe( |
| 301 | Effect.flatMap(parseTailscaleSshEnabled), |
| 302 | ); |
| 303 | |
| 304 | export 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 | |
| 317 | const 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 | |
| 370 | export 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 | |
| 381 | export 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 | |
| 394 | export 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)); |