1import { ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as NetService from "@t3tools/shared/Net";
4import { isLocalLoopbackHost, normalizeHostname } from "@t3tools/shared/hostClassification";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import * as Stream from "effect/Stream";
10import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
11import { ChildProcess, ChildProcessSpawner } from "effect/process";
12
13export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
14/**
15 * Bounds `tailscale status` and `tailscale debug prefs`. Allows the 1.8-2.6 s
16 * `tailscale status` has been measured at when T3 Code spawns it on Windows.
17 * A CLI that never answers takes longer than this to fail, because the kill
18 * that follows the timeout also takes time (`taskkill` on Windows).
19 */
20const TAILSCALE_STATUS_TIMEOUT = Duration.millis(3_500);
21/**
22 * How long a probe killed on timeout gets to exit after SIGTERM before its
23 * process group is SIGKILLed. Without it the spawner never escalates, so a group
24 * member that ignores SIGTERM outlives the call. Short because the timeout error
25 * only surfaces once cleanup finishes, and a healthy `tailscale` exits at once.
26 */
27const TAILSCALE_KILL_GRACE = Duration.millis(500);
28const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10);
29const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500);
30
31const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" =>
32 platform === "win32" ? "tailscale.exe" : "tailscale";
33
34const TailscaleCommandContext = {
35 executable: Schema.Literals(["tailscale", "tailscale.exe"]),
36 subcommand: Schema.Literals(["status", "serve", "debug"]),
37 argumentCount: Schema.Number,
38};
39
40/**
41 * Failure kinds we can name without quoting the CLI. Anything unrecognized
42 * becomes "unknown" rather than falling back to raw text — stderr can contain
43 * auth keys (`tskey-…`) and node names, and these labels are logged.
44 */
45export const TailscaleStderrDiagnostic = Schema.Literals([
46 "no-existing-handler",
47 "not-logged-in",
48 "not-running",
49 "permission-denied",
50 "unsupported-target",
51 "unknown",
52]);
53export type TailscaleStderrDiagnostic = typeof TailscaleStderrDiagnostic.Type;
54
55const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray<
56 readonly [RegExp, Exclude<TailscaleStderrDiagnostic, "unknown">]
57> = [
58 [/handler does not exist/i, "no-existing-handler"],
59 [/not logged in|logged out|needs? login/i, "not-logged-in"],
60 // Only answers that confirm no daemon; a dial can also fail for want of permission. Windows
61 // reports a missing service as its pipe not being found, without the usual prefix.
62 [
63 /doesn't appear to be running|tailscaled\.service not running|tailscale is stopped|tailscaled: the system cannot find the file specified|failed to connect to local tailscale[\s\S]*(connection refused|no such file or directory|cannot find the file)/i,
64 "not-running",
65 ],
66 [/permission denied|access denied|must be root|operation not permitted/i, "permission-denied"],
67 // The macOS apps, and tailscaled without SO_MARK, proxy only to localhost, 127.0.0.1 and ::1.
68 [/doesn't support connecting to remote destinations/i, "unsupported-target"],
69];
70
71const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => {
72 if (stderr.trim().length === 0) {
73 return undefined;
74 }
75 return STDERR_DIAGNOSTIC_PATTERNS.find(([pattern]) => pattern.test(stderr))?.[1] ?? "unknown";
76};
77
78export class TailscaleCommandSpawnError extends Schema.TaggedError<TailscaleCommandSpawnError>()(
79 "TailscaleCommandSpawnError",
80 {
81 ...TailscaleCommandContext,
82 cause: Schema.Defect(),
83 },
84) {
85 override get message(): string {
86 return `Failed to spawn tailscale ${this.subcommand}.`;
87 }
88}
89
90class TailscaleCommandOutputError extends Schema.TaggedError<TailscaleCommandOutputError>()(
91 "TailscaleCommandOutputError",
92 {
93 ...TailscaleCommandContext,
94 cause: Schema.Defect(),
95 },
96) {
97 override get message(): string {
98 return `Failed to read output from tailscale ${this.subcommand}.`;
99 }
100}
101
102export class TailscaleCommandExitError extends Schema.TaggedError<TailscaleCommandExitError>()(
103 "TailscaleCommandExitError",
104 {
105 ...TailscaleCommandContext,
106 exitCode: Schema.Number,
107 stdoutLength: Schema.optional(Schema.Number),
108 stderrLength: Schema.Number,
109 stderrDiagnostic: Schema.optional(TailscaleStderrDiagnostic),
110 },
111) {
112 override get message(): string {
113 return `tailscale ${this.subcommand} exited with code ${this.exitCode}.`;
114 }
115}
116
117export class TailscaleCommandTimeoutError extends Schema.TaggedError<TailscaleCommandTimeoutError>()(
118 "TailscaleCommandTimeoutError",
119 {
120 ...TailscaleCommandContext,
121 timeoutMs: Schema.Number,
122 cause: Schema.Defect(),
123 },
124) {
125 override get message(): string {
126 return `tailscale ${this.subcommand} timed out after ${this.timeoutMs}ms.`;
127 }
128}
129
130export type TailscaleCommandError =
131 | TailscaleCommandSpawnError
132 | TailscaleCommandOutputError
133 | TailscaleCommandExitError
134 | TailscaleCommandTimeoutError;
135
136export class TailscaleStatusParseError extends Schema.TaggedError<TailscaleStatusParseError>()(
137 "TailscaleStatusParseError",
138 { cause: Schema.Defect() },
139) {
140 override get message(): string {
141 return "Failed to decode tailscale status JSON.";
142 }
143}
144
145const TailscaleStatusSelf = Schema.Struct({
146 DNSName: Schema.optional(Schema.Unknown),
147 TailscaleIPs: Schema.optional(Schema.Unknown),
148 CapMap: Schema.optional(Schema.Unknown),
149});
150
151const TailscaleStatusJson = Schema.Struct({
152 BackendState: Schema.optional(Schema.Unknown),
153 Self: Schema.optional(TailscaleStatusSelf),
154});
155
156export type TailscaleStatusJson = typeof TailscaleStatusJson.Type;
157
158export interface TailscaleStatus {
159 /**
160 * `tailscale down` keeps `Self` from the last netmap, so the DNS name alone
161 * does not mean the tailnet is reachable. `null` when the CLI omits BackendState.
162 */
163 readonly running: boolean | null;
164 readonly magicDnsName: string | null;
165 readonly tailnetIpv4Addresses: readonly string[];
166 /**
167 * `tailscale serve --https` needs the `https` capability, which the control
168 * plane grants once the tailnet enables HTTPS certificates. `null` when the
169 * CLI reports no capability map (older CLI), so callers still attempt serve.
170 */
171 readonly httpsServeEnabled: boolean | null;
172}
173
174const collectStdout = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
175 stream.pipe(
176 Stream.decodeText(),
177 Stream.runFold(
178 () => "",
179 (acc, chunk) => acc + chunk,
180 ),
181 );
182
183const collectStderr = collectStdout;
184
185const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson));
186
187function normalizeMagicDnsName(status: TailscaleStatusJson): string | null {
188 const dnsName = status.Self?.DNSName;
189 if (typeof dnsName !== "string") {
190 return null;
191 }
192
193 const normalized = dnsName.trim().replace(/\.$/u, "");
194 return normalized.length > 0 ? normalized : null;
195}
196
197export function isTailscaleIpv4Address(address: string): boolean {
198 const parts = address.split(".");
199 if (parts.length !== 4) {
200 return false;
201 }
202 const [first, second, third, fourth] = parts.map((part) => Number.parseInt(part, 10));
203 if (
204 first === undefined ||
205 second === undefined ||
206 third === undefined ||
207 fourth === undefined ||
208 [first, second, third, fourth].some((part) => !Number.isInteger(part) || part < 0 || part > 255)
209 ) {
210 return false;
211 }
212 return first === 100 && second >= 64 && second <= 127;
213}
214
215export const parseTailscaleStatus = (
216 rawStatusJson: string,
217): Effect.Effect<TailscaleStatus, TailscaleStatusParseError> =>
218 decodeTailscaleStatusJson(rawStatusJson).pipe(
219 Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),
220 Effect.map((parsed) => {
221 const rawIps = parsed.Self?.TailscaleIPs;
222 const tailnetIpv4Addresses: Array<string> = [];
223 if (Array.isArray(rawIps)) {
224 for (const address of rawIps) {
225 if (typeof address === "string" && isTailscaleIpv4Address(address)) {
226 tailnetIpv4Addresses.push(address);
227 }
228 }
229 }
230
231 const capMap = parsed.Self?.CapMap;
232 return {
233 running: parsed.BackendState === undefined ? null : parsed.BackendState === "Running",
234 magicDnsName: normalizeMagicDnsName(parsed),
235 tailnetIpv4Addresses,
236 httpsServeEnabled:
237 typeof capMap === "object" && capMap !== null && !Array.isArray(capMap)
238 ? Object.hasOwn(capMap, "https")
239 : null,
240 };
241 }),
242 );
243
244const readTailscaleOutput = Effect.fn("tailscale.readTailscaleOutput")(function* (
245 subcommand: "status" | "serve" | "debug",
246 args: readonly string[],
247) {
248 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
249 const hostPlatform = yield* HostProcess.Platform;
250 const executable = tailscaleCommandForPlatform(hostPlatform);
251 const commandContext = {
252 executable,
253 subcommand,
254 argumentCount: args.length,
255 };
256 const command = ChildProcess.make(executable, [...args], {
257 forceKillAfter: TAILSCALE_KILL_GRACE,
258 });
259 return yield* Effect.gen(function* () {
260 const child = yield* spawner.spawn(command).pipe(
261 Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
262 // Spawning can also fail as a defect rather than a typed error - a
263 // non-directory entry on PATH makes node throw ENOTDIR synchronously.
264 // `mapError` never sees that, so it would escape as an uncaught error.
265 Effect.catchDefect((cause) =>
266 Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })),
267 ),
268 );
269 const [stdout, stderr, exitCode] = yield* Effect.all(
270 [
271 collectStdout(child.stdout),
272 collectStderr(child.stderr),
273 child.exitCode.pipe(Effect.map(Number)),
274 ],
275 { concurrency: "unbounded" },
276 ).pipe(
277 Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })),
278 );
279 if (exitCode !== 0) {
280 return yield* new TailscaleCommandExitError({
281 ...commandContext,
282 exitCode,
283 stdoutLength: stdout.length,
284 stderrLength: stderr.length,
285 ...(stderrDiagnosticOf(stderr) !== undefined
286 ? { stderrDiagnostic: stderrDiagnosticOf(stderr) }
287 : {}),
288 });
289 }
290 return stdout;
291 }).pipe(
292 Effect.scoped,
293 Effect.timeout(TAILSCALE_STATUS_TIMEOUT),
294 Effect.catchTags({
295 TimeoutError: (cause) =>
296 Effect.fail(
297 new TailscaleCommandTimeoutError({
298 ...commandContext,
299 timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT),
300 cause,
301 }),
302 ),
303 }),
304 );
305});
306
307export const readTailscaleStatus = readTailscaleOutput("status", ["status", "--json"]).pipe(
308 Effect.flatMap(parseTailscaleStatus),
309);
310
311const TailscaleExitNodeJson = Schema.Struct({
312 ExitNodeStatus: Schema.optional(Schema.Struct({ Online: Schema.optional(Schema.Unknown) })),
313 Peer: Schema.optional(
314 Schema.Record(
315 Schema.String,
316 Schema.Struct({
317 ExitNode: Schema.optional(Schema.Unknown),
318 HostName: Schema.optional(Schema.Unknown),
319 DNSName: Schema.optional(Schema.Unknown),
320 }),
321 ),
322 ),
323});
324
325const decodeTailscaleExitNodeJson = Schema.decodeEffect(
326 Schema.fromJsonString(TailscaleExitNodeJson),
327);
328
329export interface TailscaleExitNode {
330 readonly name: string | null;
331 readonly online: boolean;
332}
333
334/**
335 * `ExitNodeStatus` is present only while an exit node is in use, but it has no
336 * name. The name comes from the peer marked `ExitNode: true`.
337 */
338export const parseTailscaleExitNode = (
339 rawStatusJson: string,
340): Effect.Effect<TailscaleExitNode | null, TailscaleStatusParseError> =>
341 decodeTailscaleExitNodeJson(rawStatusJson).pipe(
342 Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),
343 Effect.map((parsed) => {
344 if (parsed.ExitNodeStatus === undefined) return null;
345 const peer = Object.values(parsed.Peer ?? {}).find(
346 (candidate) => candidate.ExitNode === true,
347 );
348 // The DNS label is the tailnet machine name; HostName is the OS name and ignores renames.
349 const dnsLabel = typeof peer?.DNSName === "string" ? (peer.DNSName.split(".")[0] ?? "") : "";
350 const hostName = typeof peer?.HostName === "string" ? peer.HostName.trim() : "";
351 return {
352 name: dnsLabel || hostName || null,
353 online: parsed.ExitNodeStatus.Online === true,
354 };
355 }),
356 );
357
358export const readTailscaleExitNode = readTailscaleOutput("status", ["status", "--json"]).pipe(
359 Effect.flatMap(parseTailscaleExitNode),
360);
361
362const TailscalePrefsJson = Schema.Struct({
363 RunSSH: Schema.optional(Schema.Unknown),
364 WantRunning: Schema.optional(Schema.Unknown),
365});
366
367const decodeTailscalePrefsJson = Schema.decodeEffect(Schema.fromJsonString(TailscalePrefsJson));
368
369/**
370 * `tailscale status --json` does not report this for the local node. `tailscale
371 * down` clears WantRunning but keeps RunSSH, so check both.
372 */
373export const parseTailscaleSshEnabled = (
374 rawPrefsJson: string,
375): Effect.Effect<boolean, TailscaleStatusParseError> =>
376 decodeTailscalePrefsJson(rawPrefsJson).pipe(
377 Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),
378 Effect.map((prefs) => prefs.RunSSH === true && prefs.WantRunning !== false),
379 );
380
381export const readTailscaleSshEnabled = readTailscaleOutput("debug", ["debug", "prefs"]).pipe(
382 Effect.flatMap(parseTailscaleSshEnabled),
383);
384
385export function buildTailscaleHttpsBaseUrl(input: {
386 readonly magicDnsName: string;
387 readonly servePort?: number;
388}): string {
389 const url = new URL(`https://${input.magicDnsName}`);
390 const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT;
391 if (servePort !== DEFAULT_TAILSCALE_SERVE_PORT) {
392 url.port = String(servePort);
393 }
394 url.pathname = "/";
395 return url.toString();
396}
397
398const runTailscaleCommand = Effect.fn("tailscale.runTailscaleCommand")(function* (
399 args: readonly string[],
400 timeoutInput: Duration.Input,
401): Effect.fn.Return<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> {
402 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
403 const hostPlatform = yield* HostProcess.Platform;
404 const executable = tailscaleCommandForPlatform(hostPlatform);
405 const commandContext = {
406 executable,
407 subcommand: "serve" as const,
408 argumentCount: args.length,
409 };
410 const timeout = Duration.fromInputUnsafe(timeoutInput);
411 return yield* Effect.gen(function* () {
412 const child = yield* spawner
413 .spawn(ChildProcess.make(executable, args, { forceKillAfter: TAILSCALE_KILL_GRACE }))
414 .pipe(
415 Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
416 Effect.catchDefect((cause) =>
417 Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })),
418 ),
419 );
420 // An unread stdout pipe fills on a long success summary and blocks the child before it exits.
421 const [, stderr, exitCode] = yield* Effect.all(
422 [
423 Stream.runDrain(child.stdout),
424 collectStderr(child.stderr),
425 child.exitCode.pipe(Effect.map(Number)),
426 ],
427 { concurrency: "unbounded" },
428 ).pipe(
429 Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })),
430 );
431 if (exitCode !== 0) {
432 return yield* new TailscaleCommandExitError({
433 ...commandContext,
434 exitCode,
435 stderrLength: stderr.length,
436 ...(stderrDiagnosticOf(stderr) !== undefined
437 ? { stderrDiagnostic: stderrDiagnosticOf(stderr) }
438 : {}),
439 });
440 }
441 }).pipe(
442 Effect.scoped,
443 Effect.timeout(timeout),
444 Effect.catchTags({
445 TimeoutError: (cause) =>
446 Effect.fail(
447 new TailscaleCommandTimeoutError({
448 ...commandContext,
449 timeoutMs: Duration.toMillis(timeout),
450 cause,
451 }),
452 ),
453 }),
454 );
455});
456
457export class TailscaleServeHttpsDisabledError extends Schema.TaggedError<TailscaleServeHttpsDisabledError>()(
458 "TailscaleServeHttpsDisabledError",
459 {},
460) {
461 override get message(): string {
462 return "Tailscale Serve needs HTTPS enabled on the tailnet. Enable HTTPS certificates at https://login.tailscale.com/admin/dns.";
463 }
464}
465
466export const ensureTailscaleServe = Effect.fn("tailscale.ensureTailscaleServe")(function* (input: {
467 readonly localPort: number;
468 readonly servePort?: number;
469 readonly localHost?: string;
470}): Effect.fn.Return<
471 void,
472 TailscaleCommandError | TailscaleServeHttpsDisabledError,
473 ChildProcessSpawner.ChildProcessSpawner
474> {
475 // Without the capability `tailscale serve` prints an enablement notice and exits 0, or blocks on
476 // it, having configured nothing either way.
477 const status = yield* Effect.option(readTailscaleStatus);
478 if (Option.isSome(status) && status.value.httpsServeEnabled === false) {
479 return yield* new TailscaleServeHttpsDisabledError();
480 }
481 const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT;
482 const localHost = input.localHost ?? "127.0.0.1";
483 const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`];
484 return yield* runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT);
485});
486
487/** A Serve route: a web handler at `mount` on `servePort`, or a TCP forward without one. */
488export interface TailscaleServeRoute {
489 readonly servePort: number;
490 readonly mount?: string;
491 /** The machine name a web route serves under. */
492 readonly host?: string;
493 /** A target, such as a name, that may dial this server or another service on the same port. */
494 readonly ambiguous?: string;
495 /** The Tailscale Service it belongs to, or `foreground` for a `tailscale serve` still running. */
496 readonly owner?: string;
497}
498
499/**
500 * The `tailscale` arguments that turn `route` off; a foreground route has none, since it ends with
501 * its command. A web route names its path: without one the CLI removes every handler on the port,
502 * and answers its own confirmation prompt with yes when nobody is there to answer.
503 */
504export const tailscaleServeOffArgs = (
505 route: TailscaleServeRoute,
506): ReadonlyArray<string> | undefined =>
507 route.owner === "foreground"
508 ? undefined
509 : [
510 "serve",
511 ...(route.owner === undefined ? [] : [`--service=${route.owner}`]),
512 ...(route.mount === undefined
513 ? [`--tcp=${route.servePort}`]
514 : [`--https=${route.servePort}`, `--set-path=${route.mount}`]),
515 "off",
516 ];
517
518/** Removes the web handler at `mount` on `servePort`, the root one `ensureTailscaleServe` adds by default. */
519export const disableTailscaleServe = Effect.fn("tailscale.disableTailscaleServe")(function* (
520 input: {
521 readonly servePort?: number;
522 readonly mount?: string;
523 } = {},
524): Effect.fn.Return<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> {
525 return yield* runTailscaleCommand(
526 tailscaleServeOffArgs({
527 servePort: input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT,
528 mount: input.mount ?? "/",
529 })!,
530 TAILSCALE_SERVE_TIMEOUT,
531 );
532});
533
534/** Removes a TCP forward on `servePort`; one port holds one forward, so nothing else goes. */
535export const disableTailscaleServeTcp = (servePort: number) =>
536 runTailscaleCommand(tailscaleServeOffArgs({ servePort })!, TAILSCALE_SERVE_TIMEOUT);
537
538const TailscaleServeConfigJson = Schema.Struct({
539 TCP: Schema.optional(
540 Schema.Record(Schema.String, Schema.Struct({ TCPForward: Schema.optional(Schema.String) })),
541 ),
542 Web: Schema.optional(
543 Schema.Record(
544 Schema.String,
545 Schema.Struct({
546 Handlers: Schema.optional(
547 Schema.Record(Schema.String, Schema.Struct({ Proxy: Schema.optional(Schema.String) })),
548 ),
549 }),
550 ),
551 ),
552});
553const TailscaleServeStatusJson = Schema.Struct({
554 ...TailscaleServeConfigJson.fields,
555 // A Tailscale Service's routes, and those of each `tailscale serve` still running in the foreground.
556 Services: Schema.optional(Schema.Record(Schema.String, TailscaleServeConfigJson)),
557 Foreground: Schema.optional(Schema.Record(Schema.String, TailscaleServeConfigJson)),
558});
559// A node without any Serve configuration prints `null`.
560const decodeTailscaleServeStatusJson = Schema.decodeUnknownOption(
561 Schema.fromJsonString(Schema.NullOr(TailscaleServeStatusJson)),
562);
563
564/**
565 * Whether a proxy to `hostname` dials `bindHost`. `0.0.0.0` reaches 127.0.0.1. `[::]` may reach
566 * either loopback, since Go dials `::` and then 0.0.0.0 (golang/go#18806), and a name such as
567 * `localhost` may resolve to whatever the server binds.
568 */
569const reaches = (hostname: string, bindHost: string): "exact" | "either" | undefined => {
570 let address = normalizeHostname(hostname);
571 // Compare IPv6 by value: 0:0:0:0:0:ffff:7f00:1 is ::ffff:7f00:1.
572 if (address.includes(":") && URL.canParse(`http://[${address}]`)) {
573 address = normalizeHostname(new URL(`http://[${address}]`).hostname);
574 }
575 // An IPv4-mapped address can be written as hex, such as ::ffff:7f00:1 for 127.0.0.1.
576 const mapped = /^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/u.exec(address);
577 if (mapped !== null) {
578 const high = Number.parseInt(mapped[1]!, 16);
579 const low = Number.parseInt(mapped[2]!, 16);
580 address = [high >>> 8, high & 0xff, low >>> 8, low & 0xff].join(".");
581 } else if (address.startsWith("::ffff:")) {
582 address = address.slice("::ffff:".length);
583 }
584 // Strict, as Go's ParseIP is; anything else, such as `127.1` or `box.1`, goes to the resolver.
585 const literal =
586 address.includes(":") ||
587 /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?!$)|$)){4}$/u.test(address);
588 if (!literal) return "either";
589 if (address === "::")
590 return bindHost === "127.0.0.1" || bindHost === "::1" ? "either" : undefined;
591 return (address === "0.0.0.0" ? "127.0.0.1" : address) === bindHost ? "exact" : undefined;
592};
593
594/** The URL Tailscale's proxy dials for a handler's `Proxy`, expanded as its `expandProxyArg` does. */
595const expandProxy = (proxy: string) =>
596 /^\d+$/u.test(proxy)
597 ? `http://127.0.0.1:${proxy}`
598 : proxy.startsWith("https+insecure://")
599 ? `https://${proxy.slice("https+insecure://".length)}`
600 : /^(?:https?:\/\/|unix:)/u.test(proxy)
601 ? proxy
602 : `http://${proxy}`;
603
604/**
605 * The host and port a proxy target dials, read as Go's url.Parse reads it rather than as a WHATWG
606 * URL, which rejects `box.1` and rewrites `127.1`; none for a Unix socket.
607 */
608const dialedHostPort = (url: string) => {
609 const match = /^(https?|tcp):\/\/(?:[^@/?#]*@)?(\[[^\]]*\]|[^/?#:]*)(?::(\d*))?/iu.exec(url);
610 if (match === null) return undefined;
611 const [, scheme = "", host = "", port] = match;
612 return { host, port: port || (scheme.toLowerCase() === "https" ? "443" : "80") };
613};
614
615const reachesTarget = (url: string, target: { readonly host: string; readonly port: number }) => {
616 const dialed = dialedHostPort(url);
617 // Go reads the port as a decimal number, so 03773 is 3773.
618 if (dialed === undefined || Number(dialed.port) !== target.port) return undefined;
619 const reach = reaches(dialed.host, target.host);
620 return reach === undefined ? undefined : { reach, hostname: dialed.host };
621};
622
623/**
624 * The Serve routes that forward to the server bound at `target`, or undefined when the status
625 * cannot be read.
626 */
627export const parseTailscaleServeRoutesTo = (
628 rawServeStatusJson: string,
629 target: { readonly host: string; readonly port: number },
630): ReadonlyArray<TailscaleServeRoute> | undefined => {
631 const status = decodeTailscaleServeStatusJson(rawServeStatusJson);
632 if (Option.isNone(status)) return undefined;
633 const routesIn = (
634 config: typeof TailscaleServeConfigJson.Type | undefined,
635 owner?: string,
636 ): ReadonlyArray<TailscaleServeRoute> => {
637 const scope = owner === undefined ? {} : { owner };
638 const reached = (url: string) => {
639 const found = reachesTarget(url, target);
640 if (found === undefined) return undefined;
641 return found.reach === "either" ? { ambiguous: found.hostname } : {};
642 };
643 const tcp = Object.entries(config?.TCP ?? {}).flatMap(([port, handler]) => {
644 const reach =
645 handler.TCPForward === undefined || handler.TCPForward.startsWith("unix:")
646 ? undefined
647 : reached(`tcp://${handler.TCPForward}`);
648 return reach !== undefined && Number.isInteger(Number(port))
649 ? [{ servePort: Number(port), ...reach, ...scope }]
650 : [];
651 });
652 const web = Object.entries(config?.Web ?? {}).flatMap(([hostPort, site]) => {
653 const separator = hostPort.lastIndexOf(":");
654 const servePort = Number(hostPort.slice(separator + 1));
655 if (!Number.isInteger(servePort)) return [];
656 const host = hostPort.slice(0, separator);
657 return Object.entries(site.Handlers ?? {}).flatMap(([mount, handler]) => {
658 const reach = handler.Proxy === undefined ? undefined : reached(expandProxy(handler.Proxy));
659 return reach === undefined ? [] : [{ servePort, mount, host, ...reach, ...scope }];
660 });
661 });
662 return [...tcp, ...web];
663 };
664 return [
665 ...routesIn(status.value ?? undefined),
666 ...Object.entries(status.value?.Services ?? {}).flatMap(([name, service]) =>
667 routesIn(service, name),
668 ),
669 ...Object.values(status.value?.Foreground ?? {}).flatMap((session) =>
670 routesIn(session, "foreground"),
671 ),
672 ];
673};
674
675/** The Serve routes that forward to the server bound at `target` on this machine. */
676export const readTailscaleServeRoutesTo = (target: {
677 readonly host: string;
678 readonly port: number;
679}) =>
680 readTailscaleOutput("serve", ["serve", "status", "--json"]).pipe(
681 Effect.flatMap((status) => {
682 const routes = parseTailscaleServeRoutesTo(status, target);
683 return routes === undefined
684 ? Effect.fail(new TailscaleStatusParseError({ cause: "unreadable serve status" }))
685 : Effect.succeed(routes);
686 }),
687 );
688
689/** A handler at the root of a Serve port: this server's, or another with the loopback port it dials. */
690export type TailscaleServeRootHolder =
691 | { readonly ours: true }
692 | { readonly ours: false; readonly loopback?: { readonly host: string; readonly port: number } };
693
694/**
695 * What answers at the root of `servePort` outside any Tailscale Service, where
696 * `ensureTailscaleServe` publishes, or undefined when the status cannot be read.
697 */
698export const parseTailscaleServeRootHolders = (
699 rawServeStatusJson: string,
700 servePort: number,
701 target: { readonly host: string; readonly port: number },
702): ReadonlyArray<TailscaleServeRootHolder> | undefined => {
703 const status = decodeTailscaleServeStatusJson(rawServeStatusJson);
704 if (Option.isNone(status)) return undefined;
705 const config = status.value ?? undefined;
706 const tcp: ReadonlyArray<TailscaleServeRootHolder> =
707 config?.TCP?.[String(servePort)]?.TCPForward === undefined ? [] : [{ ours: false }];
708 const web = Object.entries(config?.Web ?? {}).flatMap(
709 ([hostPort, site]): ReadonlyArray<TailscaleServeRootHolder> => {
710 const root = site.Handlers?.["/"];
711 if (Number(hostPort.slice(hostPort.lastIndexOf(":") + 1)) !== servePort || root === undefined)
712 return [];
713 if (root.Proxy === undefined) return [{ ours: false }];
714 const url = expandProxy(root.Proxy);
715 if (reachesTarget(url, target) !== undefined) return [{ ours: true }];
716 const dialed = dialedHostPort(url);
717 return [
718 dialed !== undefined && isLocalLoopbackHost(dialed.host)
719 ? {
720 ours: false,
721 loopback: { host: normalizeHostname(dialed.host), port: Number(dialed.port) },
722 }
723 : { ours: false },
724 ];
725 },
726 );
727 return [...tcp, ...web];
728};
729
730const readTailscaleServeRootHolders = (
731 servePort: number,
732 target: { readonly host: string; readonly port: number },
733) =>
734 readTailscaleOutput("serve", ["serve", "status", "--json"]).pipe(
735 Effect.flatMap((status) => {
736 const holders = parseTailscaleServeRootHolders(status, servePort, target);
737 return holders === undefined
738 ? Effect.fail(new TailscaleStatusParseError({ cause: "unreadable serve status" }))
739 : Effect.succeed(holders);
740 }),
741 );
742
743/**
744 * Who holds the root of `servePort`: nobody, the server at `target`, only dead loopback ports (an
745 * unclean exit leaves one), or another live server or app, whose route must be left alone.
746 */
747export const readTailscaleServeRootHolder = Effect.fn("tailscale.readTailscaleServeRootHolder")(
748 function* (servePort: number, target: { readonly host: string; readonly port: number }) {
749 const net = yield* NetService.NetService;
750 const holders = yield* readTailscaleServeRootHolders(servePort, target);
751 if (holders.length === 0) return "none" as const;
752 if (holders.some((holder) => holder.ours)) return "ours" as const;
753 const live = yield* Effect.forEach(holders, (holder) =>
754 holder.ours || holder.loopback === undefined
755 ? Effect.succeed(true)
756 : net.hasListenerOnHost(holder.loopback.port, holder.loopback.host),
757 );
758 return live.includes(true) ? ("other" as const) : ("dead" as const);
759 },
760);
761
762export const probeTailscaleHttpsEndpoint = Effect.fn("tailscale.probeTailscaleHttpsEndpoint")(
763 function* (input: {
764 readonly baseUrl: string;
765 readonly environmentId: string;
766 readonly timeout?: Duration.Input;
767 }) {
768 const client = yield* HttpClient.HttpClient;
769 const descriptor = yield* Effect.gen(function* () {
770 const url = new URL("/.well-known/t3/environment", input.baseUrl);
771 const response = yield* client
772 .execute(HttpClientRequest.get(url.toString()))
773 .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
774 // Another app or another T3 server can hold the Serve port; only our own descriptor counts.
775 return yield* HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)(response);
776 }).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT));
777
778 return Option.isSome(descriptor) && descriptor.value.environmentId === input.environmentId;
779 },
780 Effect.orElseSucceed(() => false),
781);