From b58627b686700d99a752facb652f326d85ee6586 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 15 Sep 2026 11:17:48 -0500 Subject: [PATCH] fix(macos): Stop repeated data access prompts Stop passive configuration reads from triggering repeated macOS data access prompts. Discover local Tailscale names from network interfaces and the system resolver. Keep cached lookups independent from caller cancellation. --- .../src/backend/DesktopServerExposure.test.ts | 56 +---- .../src/backend/DesktopServerExposure.ts | 30 +-- .../backend/tailscaleEndpointProvider.test.ts | 77 +++---- .../src/backend/tailscaleEndpointProvider.ts | 90 +++----- apps/desktop/src/main.ts | 2 + .../src/environment/RemoteOpenTargets.test.ts | 52 ++--- .../src/environment/RemoteOpenTargets.ts | 23 +- apps/server/src/server.ts | 7 +- .../tailscale/src/TailscaleIdentity.test.ts | 218 ++++++++++++++++++ packages/tailscale/src/TailscaleIdentity.ts | 195 ++++++++++++++++ packages/tailscale/src/index.ts | 2 + packages/tailscale/src/tailscale.test.ts | 2 + packages/tailscale/src/tailscale.ts | 6 + 13 files changed, 526 insertions(+), 234 deletions(-) create mode 100644 packages/tailscale/src/TailscaleIdentity.test.ts create mode 100644 packages/tailscale/src/TailscaleIdentity.ts diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 0914167cffb0..64d6373830b0 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -5,18 +5,14 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { TailscaleIdentityDiscovery, type TailscaleIdentity } from "@t3tools/tailscale"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; -const encoder = new TextEncoder(); - const emptyNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {}; const lanNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { en0: [ @@ -38,36 +34,6 @@ const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { ], }; -function mockSpawnerLayer(statusJson = "{}") { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - 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(encoder.encode(statusJson)), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ), - ), - ); -} - -function dieOnSpawnLayer() { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")), - ); -} - function makeEnvironmentLayer(baseDir: string, env: Record = {}) { return DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -90,7 +56,7 @@ function makeLayer(input: { readonly baseDir: string; readonly networkInterfaces?: DesktopNetworkInterfaces.NetworkInterfaces; readonly env?: Record; - readonly spawnerLayer?: Layer.Layer; + readonly tailscaleIdentity?: Effect.Effect; readonly desktopSettingsLayer?: Layer.Layer; }) { const env = { T3CODE_HOME: input.baseDir, ...input.env }; @@ -98,13 +64,16 @@ function makeLayer(input: { const networkLayer = Layer.succeed(DesktopNetworkInterfaces.DesktopNetworkInterfaces, { read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces), }); + const tailscaleIdentityLayer = Layer.succeed(TailscaleIdentityDiscovery, { + discover: input.tailscaleIdentity ?? Effect.succeed({ dnsNames: [] }), + }); return DesktopServerExposure.layer.pipe( Layer.provideMerge(input.desktopSettingsLayer ?? DesktopAppSettings.layer), Layer.provideMerge(NodeFileSystem.layer), Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(input.spawnerLayer ?? mockSpawnerLayer()), Layer.provideMerge(networkLayer), + Layer.provideMerge(tailscaleIdentityLayer), Layer.provideMerge(DesktopConfig.layerTest(env)), Layer.provideMerge(environmentLayer), ); @@ -122,8 +91,8 @@ const withHarness = ( | DesktopAppSettings.DesktopAppSettings >, env: Record = {}, - spawnerLayer?: Layer.Layer, desktopSettingsLayer?: Layer.Layer, + tailscaleIdentity?: Effect.Effect, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -136,7 +105,7 @@ const withHarness = ( baseDir, networkInterfaces, env, - ...(spawnerLayer ? { spawnerLayer } : {}), + ...(tailscaleIdentity ? { tailscaleIdentity } : {}), ...(desktopSettingsLayer ? { desktopSettingsLayer } : {}), }), ), @@ -300,7 +269,6 @@ describe("DesktopServerExposure", () => { assert.notInclude(tailscaleError.message, diskFailure.message); }), {}, - undefined, settingsLayer, ); }); @@ -348,24 +316,22 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("does not spawn the tailscale CLI while server exposure is local-only", () => + it.effect("does not discover a Tailscale identity while server exposure is local-only", () => withHarness( lanNetworkInterfaces, Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* serverExposure.configureFromSettings({ port: 4173 }); - // mode stays at default "local-only", tailscaleServeEnabled stays false. const endpoints = yield* serverExposure.getAdvertisedEndpoints; - // Only the loopback endpoint; no tailscale spawn means the dieOnSpawnLayer - // would have crashed the test if the gate was missing. assert.deepEqual( endpoints.map((endpoint) => endpoint.httpBaseUrl), ["http://127.0.0.1:4173/"], ); }), {}, - dieOnSpawnLayer(), + undefined, + Effect.die("unexpected Tailscale identity discovery"), ), ); diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index a04f4ecbc100..c9e2018d1dd1 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -9,24 +9,20 @@ import { type DesktopServerExposureMode, type DesktopServerExposureState, } from "@t3tools/contracts"; -import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale"; +import { isTailscaleIpv4Address, TailscaleIdentityDiscovery } from "@t3tools/tailscale"; import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/unstable/http/HttpClient"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; -const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60); - const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; @@ -415,23 +411,11 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const tailscaleIdentity = yield* TailscaleIdentityDiscovery; const httpClient = yield* HttpClient.HttpClient; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const stateRef = yield* Ref.make(initialRuntimeState()); - // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App - // Store Tailscale CLI lives inside Tailscale's sandbox container, so each - // spawn re-triggers the "Other apps" TCC prompt. - const cachedReadMagicDnsName = yield* Effect.cachedWithTTL( - readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), - Effect.orElseSucceed(() => null), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - ), - TAILSCALE_STATUS_CACHE_TTL, - ); - const readNetworkInterfaces = networkInterfaces.read; const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); @@ -538,9 +522,6 @@ export const make = Effect.gen(function* () { customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, }); - // Don't spawn the Tailscale CLI when the user hasn't opted into any - // network exposure. The spawn itself triggers a macOS "Other apps" - // TCC prompt on Mac App Store Tailscale builds. if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) { return coreEndpoints; } @@ -550,11 +531,8 @@ export const make = Effect.gen(function* () { serveEnabled: state.tailscaleServeEnabled, servePort: state.tailscaleServePort, networkInterfaces: currentNetworkInterfaces, - readMagicDnsName: cachedReadMagicDnsName, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Effect.provideService(HttpClient.HttpClient, httpClient), - ); + identity: yield* tailscaleIdentity.discover, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); return [...coreEndpoints, ...tailscaleEndpoints]; }).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints")); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index e8216ea99e99..1bb894bc00a5 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -2,41 +2,20 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { - parseTailscaleMagicDnsName, - resolveTailscaleAdvertisedEndpoints, -} from "./tailscaleEndpointProvider.ts"; +import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; -const unusedTailscaleExternalServicesLayer = Layer.mergeAll( - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")), - ), - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")), - ), +const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")), ); describe("tailscale endpoint provider", () => { - it.effect("parses MagicDNS names from tailscale status", () => - Effect.gen(function* () { - const dnsName = yield* parseTailscaleMagicDnsName( - `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, - ); - assert.equal(dnsName, "desktop.tail.ts.net"); - assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); - const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json")); - assert.isTrue(malformed._tag === "Failure"); - }), - ); - it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () => Effect.gen(function* () { const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: 3773, + identity: { dnsNames: ["desktop.tail.ts.net", "desktop.second-tail.ts.net"] }, networkInterfaces: { tailscale0: [ { @@ -49,7 +28,6 @@ describe("tailscale endpoint provider", () => { }, ], }, - statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, }); assert.deepEqual(endpoints, [ { @@ -92,27 +70,28 @@ describe("tailscale endpoint provider", () => { status: "unavailable", description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", }, + { + id: "tailscale-magicdns:https://desktop.second-tail.ts.net/", + label: "Tailscale HTTPS", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "https://desktop.second-tail.ts.net/", + wsBaseUrl: "wss://desktop.second-tail.ts.net/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "requires-configuration", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "unavailable", + description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }, ]); - }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), - ); - - it.effect("uses an injected magic DNS name reader instead of spawning tailscale", () => - Effect.gen(function* () { - let readerCalls = 0; - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: {}, - readMagicDnsName: Effect.sync(() => { - readerCalls += 1; - return "desktop.tail.ts.net"; - }), - }); - assert.equal(readerCalls, 1); - assert.deepEqual( - endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["https://desktop.tail.ts.net/"], - ); - }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), + }).pipe(Effect.provide(httpClientLayer)), ); it.effect( @@ -122,7 +101,7 @@ describe("tailscale endpoint provider", () => { const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: 3773, networkInterfaces: {}, - statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + identity: { dnsNames: ["desktop.tail.ts.net"] }, serveEnabled: true, probe: () => Effect.succeed(true), }); @@ -148,6 +127,6 @@ describe("tailscale endpoint provider", () => { description: "HTTPS endpoint served by Tailscale Serve.", }, ]); - }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), + }).pipe(Effect.provide(httpClientLayer)), ); }); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 5a5a20780a65..6837e0ba7cc9 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -3,19 +3,14 @@ import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/co import { buildTailscaleHttpsBaseUrl, isTailscaleIpv4Address, - parseTailscaleMagicDnsName, probeTailscaleHttpsEndpoint, - readTailscaleStatus, + type TailscaleIdentity, } from "@t3tools/tailscale"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as HttpClient from "effect/unstable/http/HttpClient"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts"; -export { parseTailscaleMagicDnsName } from "@t3tools/tailscale"; - const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { id: "tailscale", label: "Tailscale", @@ -61,15 +56,11 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( "resolveTailscaleMagicDnsAdvertisedEndpoint", )(function* (input: { - readonly dnsName: string | null; + readonly dnsName: string; readonly serveEnabled: boolean; readonly servePort?: number; readonly probe?: (baseUrl: string) => Effect.Effect; -}): Effect.fn.Return, never, HttpClient.HttpClient> { - if (!input.dnsName) { - return Option.none(); - } - +}): Effect.fn.Return { const httpBaseUrl = buildTailscaleHttpsBaseUrl({ magicDnsName: input.dnsName, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), @@ -81,66 +72,41 @@ const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( }); const isReachable = input.serveEnabled ? yield* probe : false; - return Option.some( - createAdvertisedEndpoint({ - provider: TAILSCALE_ENDPOINT_PROVIDER, - source: "desktop-addon", - id: `tailscale-magicdns:${httpBaseUrl}`, - label: "Tailscale HTTPS", - httpBaseUrl, - reachability: "private-network", - hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", - status: isReachable ? "available" : "unavailable", - description: isReachable - ? "HTTPS endpoint served by Tailscale Serve." - : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", - }), - ); + return createAdvertisedEndpoint({ + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "desktop-addon", + id: `tailscale-magicdns:${httpBaseUrl}`, + label: "Tailscale HTTPS", + httpBaseUrl, + reachability: "private-network", + hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", + status: isReachable ? "available" : "unavailable", + description: isReachable + ? "HTTPS endpoint served by Tailscale Serve." + : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }); }); +/** Resolves the desktop endpoints for every local Tailscale address and DNS name */ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")( function* (input: { readonly port: number; readonly serveEnabled?: boolean; readonly servePort?: number; readonly networkInterfaces: NetworkInterfaces; - readonly statusJson?: string | null; - readonly readMagicDnsName?: Effect.Effect< - string | null, - never, - ChildProcessSpawner.ChildProcessSpawner - >; + readonly identity: TailscaleIdentity; readonly probe?: (baseUrl: string) => Effect.Effect; - }): Effect.fn.Return< - readonly AdvertisedEndpoint[], - never, - ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient - > { + }): Effect.fn.Return { const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); - const readDnsName = - input.readMagicDnsName ?? - readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), - Effect.orElseSucceed(() => null), - ); - const dnsName = - input.statusJson === undefined - ? yield* readDnsName - : input.statusJson - ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( - Effect.orElseSucceed(() => null), - ) - : null; - const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ - dnsName, - serveEnabled: input.serveEnabled === true, - ...(input.servePort === undefined ? {} : { servePort: input.servePort }), - ...(input.probe === undefined ? {} : { probe: input.probe }), - }); + const magicDnsEndpoints = yield* Effect.forEach(input.identity.dnsNames, (dnsName) => + resolveTailscaleMagicDnsAdvertisedEndpoint({ + dnsName, + serveEnabled: input.serveEnabled === true, + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + ...(input.probe === undefined ? {} : { probe: input.probe }), + }), + ); - return Option.match(magicDnsEndpoint, { - onNone: () => ipEndpoints, - onSome: (endpoint) => [...ipEndpoints, endpoint], - }); + return [...ipEndpoints, ...magicDnsEndpoints]; }, ); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0d626a51955d..f7ac43880a8e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -17,6 +17,7 @@ import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { layer as TailscaleIdentityLayer } from "@t3tools/tailscale"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -141,6 +142,7 @@ const desktopSshLayer = desktopSshEnvironmentLayer.pipe( const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( Layer.provideMerge(DesktopNetworkInterfaces.layer), + Layer.provideMerge(TailscaleIdentityLayer), Layer.provideMerge(desktopFoundationLayer), ); diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index 2f876b9955c1..db48eed551e7 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -1,44 +1,13 @@ import { it } from "@effect/vitest"; import { HostProcessHostname } from "@t3tools/shared/hostProcess"; import * as NetService from "@t3tools/shared/Net"; +import { TailscaleIdentityDiscovery, type TailscaleIdentity } from "@t3tools/tailscale"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { describe, expect } from "vite-plus/test"; import * as RemoteOpenTargets from "./RemoteOpenTargets.ts"; -const encoder = new TextEncoder(); - -const TAILSCALE_STATUS_JSON = JSON.stringify({ - Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, -}); - -/** Spawner whose `tailscale status --json` exits with the given output. */ -const spawnerLayer = (input: { readonly exitCode: number; readonly stdout: string }) => - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.make(encoder.encode(input.stdout)), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ), - ), - ); - const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => Layer.succeed(NetService.NetService, { canListenOnHost: () => Effect.succeed(true), @@ -50,27 +19,36 @@ const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => const resolveTargets = (input: { readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean }; - readonly tailscale: { readonly exitCode: number; readonly stdout: string }; + readonly tailscale: Effect.Effect; readonly hostname: string; }) => Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe( Effect.provideService(HostProcessHostname, input.hostname), Effect.provide( RemoteOpenTargets.layer.pipe( - Layer.provide(Layer.mergeAll(netLayer(input.sshd), spawnerLayer(input.tailscale))), + Layer.provide( + Layer.mergeAll( + netLayer(input.sshd), + Layer.succeed(TailscaleIdentityDiscovery, { + discover: input.tailscale, + }), + ), + ), ), ), ); -const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON }; -const TAILSCALE_DOWN = { exitCode: 1, stdout: "" }; +const TAILSCALE_UP = Effect.succeed({ + dnsNames: ["bb-1.tail1234.ts.net"], +} satisfies TailscaleIdentity); +const TAILSCALE_DOWN = Effect.succeed({ dnsNames: [] } satisfies TailscaleIdentity); describe("RemoteOpenTargets", () => { it.effect("advertises nothing when no sshd accepts on either loopback", () => Effect.gen(function* () { const targets = yield* resolveTargets({ sshd: { ipv4: false, ipv6: false }, - tailscale: TAILSCALE_UP, + tailscale: Effect.die("unexpected Tailscale identity discovery"), hostname: "bb-1", }); expect(targets).toEqual([]); diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index c0cd658a28c7..e92e04285a23 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -2,20 +2,19 @@ * RemoteOpenTargets - resolves the SSH hostnames this environment advertises * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`). * - * The server can only check itself: sshd listening locally, tailscaled - * reporting a MagicDNS name, and the machine hostname for mDNS. Whether a - * given name resolves from the viewer's machine is inherently client-side. + * The server can only check itself: sshd listening locally, a Tailscale + * interface with a system-resolved MagicDNS name, and the machine hostname for + * mDNS. Whether a given name resolves from the viewer's machine is inherently client-side. * Targets are ordered most-reachable first (tailnet name works from anywhere * on the tailnet; `.local` only on the same LAN). */ import { type RemoteOpenTarget } from "@t3tools/contracts"; import { HostProcessHostname } from "@t3tools/shared/hostProcess"; import * as NetService from "@t3tools/shared/Net"; -import { readTailscaleStatus } from "@t3tools/tailscale"; +import { TailscaleIdentityDiscovery } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; const SSH_PORT = 22; @@ -28,8 +27,8 @@ export class RemoteOpenTargets extends Context.Service< /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const net = yield* NetService.NetService; + const tailscaleIdentity = yield* TailscaleIdentityDiscovery; const resolveTargets = Effect.gen(function* () { // No local sshd means no name can work; advertise nothing so clients @@ -46,14 +45,10 @@ export const make = Effect.gen(function* () { const targets: Array = []; - // Tailscale absent or down is the common case, not an error. - const magicDnsName = yield* readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), - Effect.orElseSucceed(() => null), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - if (magicDnsName !== null) { - targets.push({ kind: "tailscale", host: magicDnsName }); + // a missing tailnet identity is the common case, not an error + const identity = yield* tailscaleIdentity.discover; + for (const dnsName of identity.dnsNames) { + targets.push({ kind: "tailscale", host: dnsName }); } // os.hostname() may already be an FQDN (macOS often reports diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e0aeecb3f7b8..7e75b36083cd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -152,7 +152,11 @@ import { import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; +import { + disableTailscaleServe, + ensureTailscaleServe, + layer as TailscaleIdentityLayer, +} from "@t3tools/tailscale"; import { forkParked, ServerActivation } from "./serverActivation.ts"; // MCP handoff thread IDs include escaped provenance and can exceed find-my-way's @@ -550,6 +554,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(RemoteOpenTargets.layer), + Layer.provideMerge(TailscaleIdentityLayer), Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provide(NetService.layer), ); diff --git a/packages/tailscale/src/TailscaleIdentity.test.ts b/packages/tailscale/src/TailscaleIdentity.test.ts new file mode 100644 index 000000000000..02d2d8efb9cc --- /dev/null +++ b/packages/tailscale/src/TailscaleIdentity.test.ts @@ -0,0 +1,218 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; + +import { + TAILSCALE_IDENTITY_CACHE_TTL, + TAILSCALE_IDENTITY_LOOKUP_TIMEOUT, + TailscaleIdentityDiscovery, + TailscaleIdentityNode, + make, + type TailscaleIdentityNodeService, + type TailscaleNetworkInterfaces, +} from "./TailscaleIdentity.ts"; + +const discoverWithNode = ( + networkInterfaces: Effect.Effect, + reverseLookup: TailscaleIdentityNodeService["reverseLookup"], + effect: Effect.Effect, +) => + effect.pipe( + Effect.provideServiceEffect( + TailscaleIdentityDiscovery, + make.pipe( + Effect.provideService(TailscaleIdentityNode, { + networkInterfaces, + reverseLookup, + }), + ), + ), + ); + +describe("TailscaleIdentityDiscovery", () => { + it.effect("discovers stable, deduplicated ts.net names from Tailscale IPv4 addresses", () => { + const lookedUpAddresses: string[] = []; + return discoverWithNode( + Effect.succeed({ + en0: [ + { address: "100.100.0.3", family: "IPv4", internal: false }, + { address: "100.100.0.2", family: "IPv4", internal: false }, + { address: "100.100.0.2", family: "IPv4", internal: false }, + { address: "100.100.0.4", family: 6, internal: false }, + { address: "100.63.0.1", family: "IPv4", internal: false }, + { address: "100.128.0.1", family: "IPv4", internal: false }, + { address: "100.100.0.5", family: "IPv4", internal: true }, + ], + }), + (address) => { + lookedUpAddresses.push(address); + return Effect.succeed( + address === "100.100.0.2" + ? ["Machine.Tail.Example.ts.net.", "machine.tail.example.ts.net"] + : ["other.ts.net", "invalid.example.test"], + ); + }, + Effect.gen(function* () { + const service = yield* TailscaleIdentityDiscovery; + assert.deepEqual(yield* service.discover, { + dnsNames: ["machine.tail.example.ts.net", "other.ts.net"], + }); + assert.deepEqual(lookedUpAddresses, ["100.100.0.2", "100.100.0.3"]); + }), + ); + }); + + it.effect("does not reverse-resolve when no Tailscale address is present", () => { + let reverseCalls = 0; + return discoverWithNode( + Effect.succeed({ + en0: [{ address: "192.168.1.20", family: "IPv4", internal: false }], + }), + () => { + reverseCalls += 1; + return Effect.succeed([] as readonly string[]); + }, + Effect.gen(function* () { + const service = yield* TailscaleIdentityDiscovery; + assert.deepEqual(yield* service.discover, { dnsNames: [] }); + assert.deepEqual(yield* service.discover, { dnsNames: [] }); + assert.equal(reverseCalls, 0); + }), + ); + }); + + it.effect("caches empty results and reuses them for the address set TTL", () => { + let reverseCalls = 0; + return discoverWithNode( + Effect.succeed({ + tailscale0: [{ address: "100.100.0.2", family: "IPv4", internal: false }], + }), + () => { + reverseCalls += 1; + return Effect.succeed([] as readonly string[]); + }, + Effect.gen(function* () { + const service = yield* TailscaleIdentityDiscovery; + assert.deepEqual(yield* service.discover, { dnsNames: [] }); + assert.deepEqual(yield* service.discover, { dnsNames: [] }); + assert.equal(reverseCalls, 1); + + yield* TestClock.adjust(TAILSCALE_IDENTITY_CACHE_TTL); + yield* service.discover; + assert.equal(reverseCalls, 2); + }).pipe(Effect.provide(TestClock.layer())), + ); + }); + + it.effect("invalidates the cached identity when the address set changes", () => { + let currentInterfaces: TailscaleNetworkInterfaces = { + tailscale0: [{ address: "100.100.0.2", family: "IPv4", internal: false }], + }; + let reverseCalls = 0; + return discoverWithNode( + Effect.sync(() => currentInterfaces), + (address) => { + reverseCalls += 1; + return Effect.succeed([`${address}.ts.net`]); + }, + Effect.gen(function* () { + const service = yield* TailscaleIdentityDiscovery; + assert.deepEqual(yield* service.discover, { + dnsNames: ["100.100.0.2.ts.net"], + }); + + currentInterfaces = { + tailscale0: [{ address: "100.100.0.3", family: "IPv4", internal: false }], + }; + assert.deepEqual(yield* service.discover, { + dnsNames: ["100.100.0.3.ts.net"], + }); + assert.equal(reverseCalls, 2); + }), + ); + }); + + it.effect("shares concurrent lookups for the same address set", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let reverseCalls = 0; + const service = yield* discoverWithNode( + Effect.succeed({ + tailscale0: [{ address: "100.100.0.2", family: "IPv4", internal: false }], + }), + () => + Effect.gen(function* () { + reverseCalls += 1; + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return ["node.ts.net"]; + }), + Effect.service(TailscaleIdentityDiscovery), + ); + const firstFiber = yield* Effect.forkChild(service.discover); + const secondFiber = yield* Effect.forkChild(service.discover); + + yield* Deferred.await(started); + assert.equal(reverseCalls, 1); + yield* Deferred.succeed(release, undefined); + + assert.deepEqual(yield* Fiber.join(firstFiber), { dnsNames: ["node.ts.net"] }); + assert.deepEqual(yield* Fiber.join(secondFiber), { dnsNames: ["node.ts.net"] }); + }), + ); + + it.effect("keeps a shared lookup alive when its first caller is interrupted", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let reverseCalls = 0; + const service = yield* discoverWithNode( + Effect.succeed({ + tailscale0: [{ address: "100.100.0.2", family: "IPv4", internal: false }], + }), + () => + Effect.gen(function* () { + reverseCalls += 1; + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return ["node.ts.net"]; + }), + Effect.service(TailscaleIdentityDiscovery), + ); + const interruptedCaller = yield* Effect.forkChild(service.discover); + + yield* Deferred.await(started); + yield* Fiber.interrupt(interruptedCaller); + yield* Deferred.succeed(release, undefined); + + assert.deepEqual(yield* service.discover, { dnsNames: ["node.ts.net"] }); + assert.equal(reverseCalls, 1); + }), + ); + + it.effect("degrades a timed-out total lookup to an empty identity", () => + Effect.gen(function* () { + const service = yield* TailscaleIdentityDiscovery; + const fiber = yield* Effect.forkChild(service.discover); + yield* Effect.yieldNow; + yield* TestClock.adjust(TAILSCALE_IDENTITY_LOOKUP_TIMEOUT); + assert.deepEqual(yield* Fiber.join(fiber), { dnsNames: [] }); + }).pipe( + Effect.provide(TestClock.layer()), + Effect.provideServiceEffect( + TailscaleIdentityDiscovery, + make.pipe( + Effect.provideService(TailscaleIdentityNode, { + networkInterfaces: Effect.succeed({ + tailscale0: [{ address: "100.100.0.2", family: "IPv4", internal: false }], + }), + reverseLookup: () => Effect.never, + }), + ), + ), + ), + ); +}); diff --git a/packages/tailscale/src/TailscaleIdentity.ts b/packages/tailscale/src/TailscaleIdentity.ts new file mode 100644 index 000000000000..b6f514490355 --- /dev/null +++ b/packages/tailscale/src/TailscaleIdentity.ts @@ -0,0 +1,195 @@ +import * as NodeDnsPromises from "node:dns/promises"; +import * as NodeOS from "node:os"; + +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Semaphore from "effect/Semaphore"; + +import { isTailscaleIpv4Address } from "./tailscale.ts"; + +/** The identity names discovered from the local Tailscale interfaces */ +export interface TailscaleIdentity { + readonly dnsNames: readonly string[]; +} + +/** The Node OS and DNS boundary used by passive Tailscale discovery */ +export interface TailscaleIdentityNodeService { + readonly networkInterfaces: Effect.Effect; + readonly reverseLookup: (address: string) => Effect.Effect; +} + +/** The network interface shape needed by passive Tailscale discovery */ +export interface TailscaleNetworkInterfaceInfo { + readonly address: string; + readonly family: string | number; + readonly internal: boolean; +} + +/** The network interfaces read by passive Tailscale discovery */ +export type TailscaleNetworkInterfaces = Readonly< + Record +>; + +/** The Node APIs used by the Tailscale identity service */ +export class TailscaleIdentityNode extends Context.Service< + TailscaleIdentityNode, + TailscaleIdentityNodeService +>()("@t3tools/tailscale/TailscaleIdentity/TailscaleIdentityNode") {} + +/** A passive, process-free source of the local Tailscale DNS identity */ +export class TailscaleIdentityDiscovery extends Context.Service< + TailscaleIdentityDiscovery, + { + readonly discover: Effect.Effect; + } +>()("@t3tools/tailscale/TailscaleIdentity/TailscaleIdentityDiscovery") {} + +/** The cache lifetime for successful and empty identity results */ +export const TAILSCALE_IDENTITY_CACHE_TTL = Duration.seconds(60); + +/** The total time allowed for all reverse lookups in one discovery */ +export const TAILSCALE_IDENTITY_LOOKUP_TIMEOUT = Duration.millis(1_500); + +const TAILSCALE_LOOKUP_CONCURRENCY = 4; +const TAILSCALE_DNS_SUFFIX = ".ts.net"; +const TAILSCALE_IDENTITY_CACHE_TTL_MILLIS = Duration.toMillis(TAILSCALE_IDENTITY_CACHE_TTL); + +interface CachedIdentity { + readonly addressSetKey: string; + readonly expiresAt: number; + readonly result: Deferred.Deferred; +} + +const isValidDnsLabel = (label: string): boolean => + label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label); + +/** Normalizes a PTR result when it is a fully-qualified Tailscale name */ +const normalizeTailscaleDnsName = (value: string): string | undefined => { + const normalized = value.trim().replace(/\.+$/u, "").toLowerCase(); + if (!normalized.endsWith(TAILSCALE_DNS_SUFFIX)) { + return undefined; + } + + const labels = normalized.split("."); + if ( + labels.length < 3 || + normalized.length > 253 || + labels.some((label) => !isValidDnsLabel(label)) + ) { + return undefined; + } + + return normalized; +}; + +const isIpv4Family = (family: string | number): boolean => family === "IPv4" || family === 4; + +const readTailscaleIpv4Addresses = ( + networkInterfaces: TailscaleNetworkInterfaces, +): readonly string[] => { + const addresses = new Set(); + + for (const interfaceAddresses of Object.values(networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const address of interfaceAddresses) { + if ( + !address.internal && + isIpv4Family(address.family) && + isTailscaleIpv4Address(address.address) + ) { + addresses.add(address.address); + } + } + } + + return [...addresses].sort(); +}; + +const addressSetKey = (addresses: readonly string[]): string => addresses.join("\u0000"); + +const discoverForAddresses = ( + node: TailscaleIdentityNodeService, + addresses: readonly string[], +): Effect.Effect => + Effect.forEach(addresses, (address) => node.reverseLookup(address), { + concurrency: TAILSCALE_LOOKUP_CONCURRENCY, + }).pipe( + Effect.map((results) => { + const names = new Set(); + for (const result of results) { + for (const value of result) { + const name = normalizeTailscaleDnsName(value); + if (name !== undefined) { + names.add(name); + } + } + } + + return { dnsNames: [...names].sort() } satisfies TailscaleIdentity; + }), + Effect.timeout(TAILSCALE_IDENTITY_LOOKUP_TIMEOUT), + Effect.orElseSucceed(() => ({ dnsNames: [] })), + ); + +/** Builds the process-free Tailscale identity service */ +export const make = Effect.gen(function* () { + const node = yield* TailscaleIdentityNode; + const cacheLock = yield* Semaphore.make(1); + let cachedIdentity: CachedIdentity | undefined; + + const discover = Effect.gen(function* () { + const networkInterfaces = yield* node.networkInterfaces; + const addresses = readTailscaleIpv4Addresses(networkInterfaces); + const currentAddressSetKey = addressSetKey(addresses); + + const result = yield* cacheLock.withPermit( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + if ( + cachedIdentity !== undefined && + cachedIdentity.addressSetKey === currentAddressSetKey && + cachedIdentity.expiresAt > now + ) { + return cachedIdentity.result; + } + + const result = yield* Deferred.make(); + cachedIdentity = { + addressSetKey: currentAddressSetKey, + expiresAt: now + TAILSCALE_IDENTITY_CACHE_TTL_MILLIS, + result, + }; + yield* discoverForAddresses(node, addresses).pipe( + Effect.flatMap((identity) => Deferred.succeed(result, identity)), + Effect.forkDetach, + ); + + return result; + }).pipe(Effect.uninterruptible), + ); + + return yield* Deferred.await(result); + }); + + return TailscaleIdentityDiscovery.of({ discover }); +}); + +/** Node-backed OS and DNS APIs for standalone Node and desktop runtimes */ +const nodeLayer = Layer.succeed(TailscaleIdentityNode, { + networkInterfaces: Effect.try(() => NodeOS.networkInterfaces()).pipe( + Effect.orElseSucceed(() => ({})), + ), + reverseLookup: (address) => + Effect.tryPromise(() => NodeDnsPromises.lookupService(address, 0)).pipe( + Effect.map(({ hostname }) => [hostname]), + Effect.orElseSucceed(() => [] as readonly string[]), + ), +}); + +/** Live passive Tailscale identity discovery */ +export const layer = Layer.effect(TailscaleIdentityDiscovery, make).pipe(Layer.provide(nodeLayer)); diff --git a/packages/tailscale/src/index.ts b/packages/tailscale/src/index.ts index b6cba8d7b431..b6b95eec2dac 100644 --- a/packages/tailscale/src/index.ts +++ b/packages/tailscale/src/index.ts @@ -1 +1,3 @@ export * from "./tailscale.ts"; +export { TailscaleIdentityDiscovery, layer } from "./TailscaleIdentity.ts"; +export type { TailscaleIdentity } from "./TailscaleIdentity.ts"; diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 102153f6e204..6a48176fe3cd 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -140,6 +140,8 @@ describe("tailscale", () => { const dnsName = yield* parseTailscaleMagicDnsName(tailscaleStatusJson); assert.equal(dnsName, "desktop.tail.ts.net"); assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); + const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json")); + assert.isTrue(malformed._tag === "Failure"); }), ); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 3594742f274e..512670d2d0fd 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -175,6 +175,7 @@ export const parseTailscaleMagicDnsName = ( Effect.map(normalizeMagicDnsName), ); +/** Returns true when an address is in Tailscale's IPv4 CGNAT range */ export function isTailscaleIpv4Address(address: string): boolean { const parts = address.split("."); if (parts.length !== 4) { @@ -216,6 +217,11 @@ export const parseTailscaleStatus = ( }), ); +/** + * Reads Tailscale status through an external command for explicit operations + * such as pairing and Serve configuration. Passive discovery must use + * TailscaleIdentityDiscovery because this command can trigger macOS TCC prompts + */ export const readTailscaleStatus = Effect.gen(function* () { const args = ["status", "--json"]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;