diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index c222237dffa7b..bda2232af4e94 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -3,6 +3,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -111,6 +112,8 @@ function layer(input: { Layer.provideMerge(NodeFileSystem.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(input.spawnerLayer ?? layerMockSpawner()), + // The macOS apps are read without the CLI these cases answer through. + Layer.provideMerge(Layer.succeed(HostProcess.Platform, "linux")), Layer.provideMerge(layerNetwork), Layer.provideMerge(DesktopConfig.layerTest(env)), Layer.provideMerge(layerEnvironment), diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 76c2392c0bd66..d53efcc7c9d89 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -9,7 +9,7 @@ import { type DesktopServerExposureMode, type DesktopServerExposureState, } from "@t3tools/contracts"; -import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale"; +import { isTailscaleIpv4Address, readTailscaleStatusForDiscovery } from "@t3tools/tailscale"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -475,7 +475,7 @@ export const make = Effect.gen(function* () { capacity: 1, timeToLive: TAILSCALE_STATUS_CACHE_TTL, lookup: (_addresses: string) => - readTailscaleStatus.pipe( + readTailscaleStatusForDiscovery.pipe( Effect.orElseSucceed(() => null), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), ), diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 6548b2ccd8c0e..3740d76285959 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentId } from "@t3tools/contracts"; import { it as effectIt } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import type * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; @@ -232,7 +233,7 @@ const TAILSCALE_STATUS_JSON = JSON.stringify({ Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, }); -const layerTailscaleUp = Layer.succeed( +const layerTailscaleCli = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => Effect.succeed( @@ -253,6 +254,12 @@ const layerTailscaleUp = Layer.succeed( ), ); +// The macOS apps are read without the CLI these cases answer through. +const layerTailscaleUp = Layer.merge( + layerTailscaleCli, + Layer.succeed(HostProcess.Platform, "linux"), +); + const T3_DESCRIPTOR_BODY = JSON.stringify({ environmentId: "serve-test", label: "Serve test", diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 1f7d9f705620c..2b6a4e2d8ee03 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -1,8 +1,9 @@ import type { ServerDirectEndpoint } from "@t3tools/contracts"; import { buildTailscaleHttpsBaseUrl, + isTailscaleInterface, probeTailscaleHttpsEndpoint, - readTailscaleStatus, + readTailscaleStatusForDiscovery, } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -16,11 +17,7 @@ import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { - formatHostForUrl, - isTailnetHost, - isWildcardHost, -} from "@t3tools/shared/hostClassification"; +import { formatHostForUrl, isWildcardHost } from "@t3tools/shared/hostClassification"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "./ServerEnvironment.ts"; @@ -43,14 +40,6 @@ export class DirectEndpoints extends Context.Service< const isAdvertisableAddress = (address: string): boolean => NodeNet.isIPv4(address) && !address.startsWith("127.") && isPrivateNetworkAddress(address); -// macOS utun names need Tailscale's IPv6 range to identify them; WARP shares its IPv4 range. -const isTailscaleInterface = ( - name: string, - entries: ReadonlyArray, -): boolean => - /^tailscale/i.test(name) || - entries.some((entry) => entry.family === "IPv6" && isTailnetHost(entry.address)); - // Windows adapter names are mutable; Linux bridges can inherit these MACs. const WINDOWS_HOST_ADAPTER_MAC = /^(0a:00:27|00:50:56:c0:00):/i; @@ -149,7 +138,7 @@ export const make = Effect.gen(function* () { const resolve = Effect.gen(function* () { // Serve setup can fail without stopping startup; advertise only after it answers as this server. if (config.tailscaleServeEnabled) { - const servedUrl = yield* readTailscaleStatus.pipe( + const servedUrl = yield* readTailscaleStatusForDiscovery.pipe( Effect.map((status) => status.magicDnsName), Effect.flatMap((magicDnsName) => { if (magicDnsName === null) return Effect.succeed(null); diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index 0d0da302efa5c..490707ce2add1 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -3,6 +3,7 @@ import * as NodeModule from "node:module"; import { it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; +import { TailscaleHost } from "@t3tools/tailscale"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; @@ -78,11 +79,14 @@ const resolveTargets = (input: { readonly tailscale: { readonly status: CommandResult; readonly prefs: CommandResult }; readonly hostname: string; readonly username?: string | null; + readonly platform?: NodeJS.Platform; }) => { const resolve = Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets(), ).pipe( Effect.provideService(HostProcess.Hostname, input.hostname), + // The macOS apps are read without the CLI these cases answer through. + Effect.provideService(HostProcess.Platform, input.platform ?? "linux"), Effect.provide( RemoteOpenTargets.layer.pipe( Layer.provide(Layer.mergeAll(layerNet(input.sshd), layerSpawner(input.tailscale))), @@ -188,6 +192,48 @@ describe("RemoteOpenTargets", () => { }), ); + it.effect("advertises the tailnet address when the macOS app gives no name", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: true, ipv6: false }, + tailscale: TAILSCALE_DOWN, + hostname: "bb-1", + username: "t3-user", + platform: "darwin", + }).pipe( + Effect.provideService(TailscaleHost, { + networkInterfaces: () => ({ + utun4: [ + { + address: "100.64.1.2", + netmask: "255.255.255.255", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "100.64.1.2/32", + }, + { + address: "fd7a:115c:a1e0::1", + netmask: "ffff:ffff:ffff::", + family: "IPv6", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "fd7a:115c:a1e0::1/48", + scopeid: 0, + }, + ], + }), + tailscaledAnswers: Effect.succeed(false), + lookupMagicDnsName: () => Effect.succeed(null), + }), + ); + expect(targets).toEqual([ + { kind: "tailscale", host: "100.64.1.2", username: "t3-user" }, + { kind: "mdns", host: "bb-1.local", username: "t3-user" }, + ]); + }), + ); + it.effect("accepts an sshd bound to IPv6 loopback only", () => Effect.gen(function* () { const targets = yield* resolveTargets({ diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index beb216339f06a..d8c760b06e92f 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -1,7 +1,10 @@ import { type RemoteOpenTarget } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; -import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale"; +import { + readTailscaleSshEnabledForDiscovery, + readTailscaleStatusForDiscovery, +} from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -33,43 +36,46 @@ export const make = Effect.gen(function* () { ); const username = yield* HostProcess.Username; - const tailscaleStatus = yield* readTailscaleStatus.pipe( + const tailscaleStatus = yield* readTailscaleStatusForDiscovery.pipe( Effect.orElseSucceed(() => null), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - const magicDnsName = - tailscaleStatus?.running === false ? null : (tailscaleStatus?.magicDnsName ?? null); + // With MagicDNS off this machine has no name to give, and its tailnet address still routes. + const tailnetHost = + tailscaleStatus?.running === false + ? null + : (tailscaleStatus?.magicDnsName ?? tailscaleStatus?.tailnetIpv4Addresses[0] ?? null); const targets: Array = []; if (!sshdListening) { // sshd may listen only on the tailnet address (ListenAddress), and Tailscale SSH // needs no local sshd; tailscaled alone does not prove an SSH route. - if (magicDnsName === null) return targets; + if (tailnetHost === null) return targets; const tailnetSshdListening = yield* Effect.forEach( tailscaleStatus?.tailnetIpv4Addresses ?? [], (address) => net.hasListenerOnHost(SSH_PORT, address), ).pipe(Effect.map((listening) => listening.includes(true))); const sshRoute = tailnetSshdListening || - (yield* readTailscaleSshEnabled.pipe( + (yield* readTailscaleSshEnabledForDiscovery.pipe( Effect.orElseSucceed(() => false), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), )); if (sshRoute) { targets.push({ kind: "tailscale", - host: magicDnsName, + host: tailnetHost, ...(username === null ? {} : { username }), }); } return targets; } - if (magicDnsName !== null) { + if (tailnetHost !== null) { targets.push({ kind: "tailscale", - host: magicDnsName, + host: tailnetHost, ...(username === null ? {} : { username }), }); } diff --git a/packages/tailscale/src/discovery.test.ts b/packages/tailscale/src/discovery.test.ts new file mode 100644 index 0000000000000..f12ba8157b7ce --- /dev/null +++ b/packages/tailscale/src/discovery.test.ts @@ -0,0 +1,155 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/process"; +import type * as NodeOS from "node:os"; + +import { + readTailscaleSshEnabledForDiscovery, + readTailscaleStatusForDiscovery, + TailscaleHost, +} from "./discovery.ts"; + +const entry = (address: string): NodeOS.NetworkInterfaceInfo => + address.includes(":") + ? { + address, + netmask: "ffff:ffff:ffff:ffff::", + family: "IPv6", + mac: "00:00:00:00:00:00", + internal: false, + cidr: `${address}/64`, + scopeid: 0, + } + : { + address, + netmask: "255.255.255.255", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: false, + cidr: `${address}/32`, + }; + +// WARP comes first and shares Tailscale's IPv4 range; only Tailscale has the fd7a address. +const WARP = { utun3: [entry("100.96.0.1"), entry("2606:4700:110::1")] }; +const TAILSCALE = { utun4: [entry("100.64.1.2"), entry("fd7a:115c:a1e0::1")] }; + +const CLI_STATUS = JSON.stringify({ + BackendState: "Running", + Self: { DNSName: "from-cli.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, +}); + +const read = (input: { + readonly platform: NodeJS.Platform; + readonly interfaces: ReturnType; + readonly tailscaledSocket?: boolean; + readonly names?: Readonly>; +}) => + Effect.gen(function* () { + const spawns = yield* Ref.make(0); + const spawner = ChildProcessSpawner.make(() => + Effect.as( + Ref.update(spawns, (count) => count + 1), + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(new TextEncoder().encode(CLI_STATUS)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + const [status, sshEnabled] = yield* Effect.all([ + readTailscaleStatusForDiscovery, + readTailscaleSshEnabledForDiscovery, + ]).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HostProcess.Platform, input.platform), + Layer.succeed(TailscaleHost, { + networkInterfaces: () => input.interfaces, + tailscaledAnswers: Effect.succeed(input.tailscaledSocket === true), + lookupMagicDnsName: (address) => Effect.succeed(input.names?.[address] ?? null), + }), + ), + ), + ); + return { status, sshEnabled, spawns: yield* Ref.get(spawns) }; + }); + +describe("Tailscale discovery", () => { + it.effect("reads the macOS app's interface instead of starting its CLI", () => + Effect.gen(function* () { + const result = yield* read({ + platform: "darwin", + interfaces: { ...WARP, ...TAILSCALE }, + names: { "100.64.1.2": "bb-1.tail1234.ts.net", "100.96.0.1": "warp.example" }, + }); + assert.deepStrictEqual(result, { + status: { + running: true, + magicDnsName: "bb-1.tail1234.ts.net", + tailnetIpv4Addresses: ["100.64.1.2"], + httpsServeEnabled: null, + }, + sshEnabled: false, + spawns: 0, + }); + }), + ); + + it.effect("names a tailnet that has IPv4 turned off by its IPv6 address", () => + Effect.gen(function* () { + const result = yield* read({ + platform: "darwin", + interfaces: { ...WARP, utun4: [entry("fd7a:115c:a1e0::1")] }, + names: { "fd7a:115c:a1e0::1": "bb-1.tail1234.ts.net" }, + }); + assert.deepStrictEqual(result.status, { + running: true, + magicDnsName: "bb-1.tail1234.ts.net", + tailnetIpv4Addresses: [], + httpsServeEnabled: null, + }); + assert.strictEqual(result.spawns, 0); + }), + ); + + it.effect("reports Tailscale as stopped when only another VPN holds its IPv4 range", () => + Effect.gen(function* () { + const result = yield* read({ + platform: "darwin", + interfaces: WARP, + names: { "100.96.0.1": "warp.example" }, + }); + assert.strictEqual(result.status.running, false); + assert.strictEqual(result.status.magicDnsName, null); + assert.deepStrictEqual(result.status.tailnetIpv4Addresses, []); + assert.strictEqual(result.spawns, 0); + }), + ); + + it.effect("asks the CLI where it is not the macOS app's", () => + Effect.gen(function* () { + for (const host of [ + { platform: "darwin", tailscaledSocket: true }, + { platform: "linux" }, + ] as const) { + const result = yield* read({ ...host, interfaces: TAILSCALE }); + assert.strictEqual(result.status.magicDnsName, "from-cli.tail1234.ts.net"); + assert.strictEqual(result.spawns, 2); + } + }), + ); +}); diff --git a/packages/tailscale/src/discovery.ts b/packages/tailscale/src/discovery.ts new file mode 100644 index 0000000000000..6f6690d24fa27 --- /dev/null +++ b/packages/tailscale/src/discovery.ts @@ -0,0 +1,107 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { isTailnetHost } from "@t3tools/shared/hostClassification"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as NodeDns from "node:dns"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; + +import { + isTailscaleIpv4Address, + readTailscaleSshEnabled, + readTailscaleStatus, + type TailscaleStatus, +} from "./tailscale.ts"; + +type NetworkInterfaces = ReturnType; + +/** Tailscale's own resolver. It answers only while Tailscale is up, and only for its tailnet. */ +const MAGIC_DNS_RESOLVER = "100.100.100.100"; +const MAGIC_DNS_TIMEOUT_MS = 1_000; +/** Only the open-source tailscaled makes this; the macOS apps serve their CLI over loopback TCP. */ +const TAILSCALED_SOCKET = "/var/run/tailscaled.socket"; + +// macOS utun names need Tailscale's IPv6 range to identify them; WARP shares its IPv4 range. +export const isTailscaleInterface = ( + name: string, + entries: ReadonlyArray, +): boolean => + /^tailscale/i.test(name) || + entries.some((entry) => entry.family === "IPv6" && isTailnetHost(entry.address)); + +const lookupMagicDnsName = (address: string) => + Effect.callback((resume) => { + const resolver = new NodeDns.Resolver({ timeout: MAGIC_DNS_TIMEOUT_MS, tries: 1 }); + resolver.setServers([MAGIC_DNS_RESOLVER]); + resolver.reverse(address, (error, names) => { + const name = error ? "" : (names[0] ?? "").trim().replace(/\.$/u, ""); + resume(Effect.succeed(name.length > 0 ? name : null)); + }); + return Effect.sync(() => resolver.cancel()); + }); + +const tailscaledAnswers = Effect.callback((resume) => { + const socket = NodeNet.connect({ path: TAILSCALED_SOCKET }); + socket.once("connect", () => { + socket.destroy(); + resume(Effect.succeed(true)); + }); + socket.once("error", () => resume(Effect.succeed(false))); + return Effect.sync(() => socket.destroy()); +}); + +/** What discovery reads from the machine instead of the CLI; a reference so tests can replace it. */ +export const TailscaleHost = Context.Reference<{ + readonly networkInterfaces: () => NetworkInterfaces; + readonly tailscaledAnswers: Effect.Effect; + readonly lookupMagicDnsName: (address: string) => Effect.Effect; +}>("@t3tools/tailscale/TailscaleHost", { + defaultValue: () => ({ + networkInterfaces: () => NodeOS.networkInterfaces(), + tailscaledAnswers, + lookupMagicDnsName, + }), +}); + +/** + * The macOS apps keep their CLI in the app bundle, where each run reads the app's own container. + * macOS reports that as T3 Code asking for another app's data, and asks again on later runs. + */ +const cliIsMacApp = Effect.gen(function* () { + if ((yield* HostProcess.Platform) !== "darwin") return false; + return !(yield* (yield* TailscaleHost).tailscaledAnswers); +}); + +const readStatusWithoutCli = Effect.gen(function* () { + const host = yield* TailscaleHost; + const entries = Object.entries(host.networkInterfaces()).flatMap(([name, entries = []]) => + isTailscaleInterface(name, entries) ? entries : [], + ); + const tailnetIpv4Addresses = entries + .filter((entry) => entry.family === "IPv4" && isTailscaleIpv4Address(entry.address)) + .map((entry) => entry.address); + // A tailnet can turn IPv4 off, which leaves the interface only its fd7a address. + const address = + tailnetIpv4Addresses[0] ?? entries.find((entry) => isTailnetHost(entry.address))?.address; + const status: TailscaleStatus = { + // The apps remove their interface when Tailscale disconnects, so its presence means it is up. + running: entries.length > 0, + magicDnsName: address === undefined ? null : yield* host.lookupMagicDnsName(address), + tailnetIpv4Addresses, + httpsServeEnabled: null, + }; + return status; +}); + +/** + * `readTailscaleStatus` for discovery nobody asked for, such as on every client connect: it starts + * no process where the CLI is the macOS app's. `httpsServeEnabled` is unknown there. + */ +export const readTailscaleStatusForDiscovery = Effect.flatMap(cliIsMacApp, (macApp) => + macApp ? readStatusWithoutCli : readTailscaleStatus, +); + +/** `readTailscaleSshEnabled` for the same discovery. The macOS apps cannot serve Tailscale SSH. */ +export const readTailscaleSshEnabledForDiscovery = Effect.flatMap(cliIsMacApp, (macApp) => + macApp ? Effect.succeed(false) : readTailscaleSshEnabled, +); diff --git a/packages/tailscale/src/index.ts b/packages/tailscale/src/index.ts index b6cba8d7b4314..b596fb697d639 100644 --- a/packages/tailscale/src/index.ts +++ b/packages/tailscale/src/index.ts @@ -1 +1,2 @@ export * from "./tailscale.ts"; +export * from "./discovery.ts";