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;