From 651eda68ba0a05a2b1d7fa27a672e9120b49ddd0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 18:26:57 +0000 Subject: [PATCH 1/7] test(server): reproduce repeated tailscale spawns from remote-open targets (#11929) These tests reproduce pingdotgg/t3code#11929: resolving remote-open targets repeatedly from one RemoteOpenTargets service, with no network change between calls, spawns `tailscale status --json` and `tailscale debug prefs` on every call, and each spawn can raise a macOS "access data from other apps" prompt. Co-Authored-By: Claude Opus 5.5 --- .../src/environment/RemoteOpenTargets.test.ts | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index 3e66d5f707a0d..c2da6d01c6bb1 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -29,11 +29,15 @@ interface CommandResult { readonly stdout: string; } -const layerSpawner = (input: { readonly status: CommandResult; readonly prefs: CommandResult }) => +const layerSpawner = ( + input: { readonly status: CommandResult; readonly prefs: CommandResult }, + spawnedArgs?: Array>, +) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { const args = ChildProcess.isStandardCommand(command) ? command.args : []; + spawnedArgs?.push(args); const result = args[0] === "debug" ? input.prefs : input.status; return Effect.succeed( ChildProcessSpawner.makeHandle({ @@ -137,6 +141,35 @@ describe("RemoteOpenTargets", () => { }), ); + it.effect("reads Tailscale once across repeated resolves while nothing changes", () => { + const spawnedArgs: Array> = []; + return Effect.gen(function* () { + const service = yield* RemoteOpenTargets.RemoteOpenTargets; + for (let connect = 0; connect < 3; connect++) { + expect(yield* service.resolveTargets()).toEqual([ + { kind: "tailscale", host: "bb-1.tail1234.ts.net", username: "t3-user" }, + ]); + } + expect({ + status: spawnedArgs.filter((args) => args[0] === "status").length, + debugPrefs: spawnedArgs.filter((args) => args[0] === "debug").length, + }).toEqual({ status: 1, debugPrefs: 1 }); + }).pipe( + Effect.provideService(HostProcess.Hostname, "bb-1"), + Effect.provideService(HostProcess.Username, "t3-user"), + Effect.provide( + RemoteOpenTargets.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerNet({ ipv4: false, ipv6: false }), + layerSpawner(TAILSCALE_SSH, spawnedArgs), + ), + ), + ), + ), + ); + }); + it.effect("accepts an sshd bound to IPv6 loopback only", () => Effect.gen(function* () { const targets = yield* resolveTargets({ From 50af6e6a498d50df4313d3c9da82c0689d1d5e5b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 18:31:27 +0000 Subject: [PATCH 2/7] fix(server): remote-open targets stop re-spawning tailscale on every connect On macOS each `tailscale` spawn can raise a "would like to access data from other apps" prompt, and remote-open targets resolved on every server config read, spawning `tailscale status --json` (and `debug prefs` without sshd) each time. Cache both reads for 60 s keyed by the tailnet IPv4 interface addresses, as the desktop already does for its status read, so a network change still refetches. `debug prefs` stays lazy in the no-sshd branch. Fixes pingdotgg/t3code#11929 Co-Authored-By: Claude Opus 5.5 --- .../src/environment/RemoteOpenTargets.ts | 65 +++++++++++++++---- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index 8b4997f695d15..3e79b09978c9e 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -1,13 +1,38 @@ 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 { + isTailscaleIpv4Address, + readTailscaleSshEnabled, + readTailscaleStatus, +} from "@t3tools/tailscale"; +import * as Cache from "effect/Cache"; 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 ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as NodeOS from "node:os"; const SSH_PORT = 22; +const TAILSCALE_CACHE_TTL = Duration.seconds(60); + +const tailscaleIpv4AddressKey = (): string => + [ + ...new Set( + Object.values(NodeOS.networkInterfaces()) + .flatMap((addresses) => addresses ?? []) + .filter( + (address) => + !address.internal && + address.family === "IPv4" && + isTailscaleIpv4Address(address.address), + ) + .map((address) => address.address), + ), + ] + .toSorted() + .join(","); export class RemoteOpenTargets extends Context.Service< RemoteOpenTargets, @@ -21,6 +46,28 @@ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const net = yield* NetService.NetService; + // Every `tailscale` spawn can raise a macOS "access data from other apps" prompt, and + // targets resolve on each client connect. Key by tailnet addresses so network changes refetch. + const magicDnsNameCache = yield* Cache.make({ + capacity: 1, + timeToLive: TAILSCALE_CACHE_TTL, + lookup: (_addresses: string) => + readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.orElseSucceed(() => null), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + }); + const sshEnabledCache = yield* Cache.make({ + capacity: 1, + timeToLive: TAILSCALE_CACHE_TTL, + lookup: (_addresses: string) => + readTailscaleSshEnabled.pipe( + Effect.orElseSucceed(() => false), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + }); + const resolveTargets = Effect.gen(function* () { // Check both loopback families: sshd can be bound IPv6-only. const sshdListening = yield* Effect.zipWith( @@ -29,24 +76,14 @@ export const make = Effect.gen(function* () { (ipv4, ipv6) => ipv4 || ipv6, ); const username = yield* HostProcess.Username; - - const magicDnsName = yield* readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), - Effect.orElseSucceed(() => null), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); + const tailscaleKey = tailscaleIpv4AddressKey(); + const magicDnsName = yield* Cache.get(magicDnsNameCache, tailscaleKey); const targets: Array = []; if (!sshdListening) { // Tailscale SSH needs no local sshd; tailscaled alone does not prove an SSH route. - if ( - magicDnsName !== null && - (yield* readTailscaleSshEnabled.pipe( - Effect.orElseSucceed(() => false), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - )) - ) { + if (magicDnsName !== null && (yield* Cache.get(sshEnabledCache, tailscaleKey))) { targets.push({ kind: "tailscale", host: magicDnsName, From ebba0086bc7354e7d84968f415fa891e8f58f007 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 18:52:25 +0000 Subject: [PATCH 3/7] fix(server): share one address-keyed Tailscale read across connect-time lookups Remote-open targets and direct endpoints now read `tailscale status` (and `debug prefs`) through a TailscaleStatusCache service. A successful read is kept until the set of tailnet IPv4 addresses changes; a failed read expires after 60 s. A reconnect no longer spawns the CLI, so macOS stops repeating its "access data from other apps" prompt. Co-Authored-By: Claude Opus 5.5 --- .../src/environment/DirectEndpoints.test.ts | 7 +- .../server/src/environment/DirectEndpoints.ts | 13 +-- .../src/environment/RemoteOpenTargets.test.ts | 88 +++++++++++++++---- .../src/environment/RemoteOpenTargets.ts | 65 +++----------- .../src/environment/TailscaleStatusCache.ts | 73 +++++++++++++++ apps/server/src/server.ts | 2 + 6 files changed, 167 insertions(+), 81 deletions(-) create mode 100644 apps/server/src/environment/TailscaleStatusCache.ts diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 08b16959e8646..aac46e9a659bf 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -13,6 +13,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import * as DirectEndpoints from "./DirectEndpoints.ts"; import { resolveBoundEndpoints } from "./DirectEndpoints.ts"; +import * as TailscaleStatusCache from "./TailscaleStatusCache.ts"; const INTERFACES: ReturnType = { lo0: [ @@ -218,7 +219,11 @@ const resolveWithServe = (probeStatus: number) => Effect.provide( DirectEndpoints.layer.pipe( Layer.provide( - Layer.mergeAll(layerServeConfig, layerTailscaleUp, layerServeProbe(probeStatus)), + Layer.mergeAll( + layerServeConfig, + TailscaleStatusCache.layer.pipe(Layer.provide(layerTailscaleUp)), + layerServeProbe(probeStatus), + ), ), ), ), diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 99e33b98f9709..0db0b5d1cdd18 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -1,16 +1,11 @@ import type { ServerDirectEndpoint } from "@t3tools/contracts"; -import { - buildTailscaleHttpsBaseUrl, - probeTailscaleHttpsEndpoint, - readTailscaleStatus, -} from "@t3tools/tailscale"; +import { buildTailscaleHttpsBaseUrl, probeTailscaleHttpsEndpoint } 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 { HttpClient } from "effect/http"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; @@ -24,6 +19,7 @@ import { isWildcardHost, VIRTUAL_INTERFACE, } from "../startupAccess.ts"; +import * as TailscaleStatusCache from "./TailscaleStatusCache.ts"; type NetworkInterfacesMap = ReturnType; @@ -109,7 +105,7 @@ const probeCloudMetadata = Effect.callback((resume) => { /** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const tailscale = yield* TailscaleStatusCache.TailscaleStatusCache; const httpClient = yield* HttpClient.HttpClient; // Only the service default listens on every interface without being asked, // so only it checks once whether it runs in a cloud. The check starts now so @@ -130,7 +126,7 @@ export const make = 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* tailscale.readStatus().pipe( Effect.map((status) => status.magicDnsName), Effect.flatMap((magicDnsName) => { if (magicDnsName === null) return Effect.succeed(null); @@ -145,7 +141,6 @@ export const make = Effect.gen(function* () { Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT), Effect.map(Option.flatMap(Option.fromNullishOr)), Effect.orElseSucceed(() => Option.none()), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), ); if (Option.isSome(servedUrl)) { diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index c2da6d01c6bb1..b6e88aa47bbfc 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -7,11 +7,14 @@ 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 TestClock from "effect/testing/TestClock"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import type * as NodeOS from "node:os"; import { afterEach, describe, expect, vi } from "vite-plus/test"; import * as RemoteOpenTargets from "./RemoteOpenTargets.ts"; +import * as TailscaleStatusCache from "./TailscaleStatusCache.ts"; afterEach(() => { vi.restoreAllMocks(); @@ -20,6 +23,25 @@ afterEach(() => { const encoder = new TextEncoder(); +const nodeOs: typeof NodeOS = NodeModule.createRequire(import.meta.url)("node:os"); + +const useTailnetAddress = (address: string) => + Effect.sync(() => { + vi.spyOn(nodeOs, "networkInterfaces").mockReturnValue({ + tailscale0: [ + { + address, + netmask: "255.255.255.255", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: false, + cidr: `${address}/32`, + }, + ], + }); + NodeModule.syncBuiltinESMExports(); + }); + const TAILSCALE_STATUS_JSON = JSON.stringify({ Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, }); @@ -66,6 +88,15 @@ const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => findAvailablePort: (preferred) => Effect.succeed(preferred), }); +const layerTargets = ( + sshd: { readonly ipv4: boolean; readonly ipv6: boolean }, + spawner: Layer.Layer, +) => + RemoteOpenTargets.layer.pipe( + Layer.provide(layerNet(sshd)), + Layer.provide(TailscaleStatusCache.layer.pipe(Layer.provide(spawner))), + ); + const resolveTargets = (input: { readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean }; readonly tailscale: { readonly status: CommandResult; readonly prefs: CommandResult }; @@ -76,11 +107,7 @@ const resolveTargets = (input: { service.resolveTargets(), ).pipe( Effect.provideService(HostProcess.Hostname, input.hostname), - Effect.provide( - RemoteOpenTargets.layer.pipe( - Layer.provide(Layer.mergeAll(layerNet(input.sshd), layerSpawner(input.tailscale))), - ), - ), + Effect.provide(layerTargets(input.sshd, layerSpawner(input.tailscale))), ); return input.username === undefined @@ -141,31 +168,56 @@ describe("RemoteOpenTargets", () => { }), ); - it.effect("reads Tailscale once across repeated resolves while nothing changes", () => { + it.effect("keeps a Tailscale read until the tailnet addresses change", () => { const spawnedArgs: Array> = []; + const spawned = () => ({ + status: spawnedArgs.filter((args) => args[0] === "status").length, + debugPrefs: spawnedArgs.filter((args) => args[0] === "debug").length, + }); return Effect.gen(function* () { const service = yield* RemoteOpenTargets.RemoteOpenTargets; + yield* useTailnetAddress("100.64.1.2"); for (let connect = 0; connect < 3; connect++) { expect(yield* service.resolveTargets()).toEqual([ { kind: "tailscale", host: "bb-1.tail1234.ts.net", username: "t3-user" }, ]); + yield* TestClock.adjust("2 minutes"); } - expect({ - status: spawnedArgs.filter((args) => args[0] === "status").length, - debugPrefs: spawnedArgs.filter((args) => args[0] === "debug").length, - }).toEqual({ status: 1, debugPrefs: 1 }); + expect(spawned()).toEqual({ status: 1, debugPrefs: 1 }); + + yield* useTailnetAddress("100.64.1.3"); + yield* service.resolveTargets(); + expect(spawned()).toEqual({ status: 2, debugPrefs: 2 }); + }).pipe( + Effect.provideService(HostProcess.Hostname, "bb-1"), + Effect.provideService(HostProcess.Username, "t3-user"), + Effect.provide( + layerTargets({ ipv4: false, ipv6: false }, layerSpawner(TAILSCALE_SSH, spawnedArgs)), + ), + ); + }); + + it.effect("retries a failed Tailscale read after a minute", () => { + const spawnedArgs: Array> = []; + const tailscale = { ...TAILSCALE_SSH, status: TAILSCALE_DOWN.status }; + const statusReads = () => spawnedArgs.filter((args) => args[0] === "status").length; + return Effect.gen(function* () { + const service = yield* RemoteOpenTargets.RemoteOpenTargets; + expect(yield* service.resolveTargets()).toEqual([]); + tailscale.status = STATUS_UP; + expect(yield* service.resolveTargets()).toEqual([]); + expect(statusReads()).toBe(1); + + yield* TestClock.adjust("61 seconds"); + expect(yield* service.resolveTargets()).toEqual([ + { kind: "tailscale", host: "bb-1.tail1234.ts.net", username: "t3-user" }, + ]); + expect(statusReads()).toBe(2); }).pipe( Effect.provideService(HostProcess.Hostname, "bb-1"), Effect.provideService(HostProcess.Username, "t3-user"), Effect.provide( - RemoteOpenTargets.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerNet({ ipv4: false, ipv6: false }), - layerSpawner(TAILSCALE_SSH, spawnedArgs), - ), - ), - ), + layerTargets({ ipv4: false, ipv6: false }, layerSpawner(tailscale, spawnedArgs)), ), ); }); diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index 3e79b09978c9e..d2558e20b9814 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -1,38 +1,13 @@ import { type RemoteOpenTarget } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; -import { - isTailscaleIpv4Address, - readTailscaleSshEnabled, - readTailscaleStatus, -} from "@t3tools/tailscale"; -import * as Cache from "effect/Cache"; 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 ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as NodeOS from "node:os"; -const SSH_PORT = 22; -const TAILSCALE_CACHE_TTL = Duration.seconds(60); +import * as TailscaleStatusCache from "./TailscaleStatusCache.ts"; -const tailscaleIpv4AddressKey = (): string => - [ - ...new Set( - Object.values(NodeOS.networkInterfaces()) - .flatMap((addresses) => addresses ?? []) - .filter( - (address) => - !address.internal && - address.family === "IPv4" && - isTailscaleIpv4Address(address.address), - ) - .map((address) => address.address), - ), - ] - .toSorted() - .join(","); +const SSH_PORT = 22; export class RemoteOpenTargets extends Context.Service< RemoteOpenTargets, @@ -43,31 +18,9 @@ export class RemoteOpenTargets extends Context.Service< /** @public */ export const make = Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const tailscale = yield* TailscaleStatusCache.TailscaleStatusCache; const net = yield* NetService.NetService; - // Every `tailscale` spawn can raise a macOS "access data from other apps" prompt, and - // targets resolve on each client connect. Key by tailnet addresses so network changes refetch. - const magicDnsNameCache = yield* Cache.make({ - capacity: 1, - timeToLive: TAILSCALE_CACHE_TTL, - lookup: (_addresses: string) => - readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), - Effect.orElseSucceed(() => null), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - }); - const sshEnabledCache = yield* Cache.make({ - capacity: 1, - timeToLive: TAILSCALE_CACHE_TTL, - lookup: (_addresses: string) => - readTailscaleSshEnabled.pipe( - Effect.orElseSucceed(() => false), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - }); - const resolveTargets = Effect.gen(function* () { // Check both loopback families: sshd can be bound IPv6-only. const sshdListening = yield* Effect.zipWith( @@ -76,14 +29,20 @@ export const make = Effect.gen(function* () { (ipv4, ipv6) => ipv4 || ipv6, ); const username = yield* HostProcess.Username; - const tailscaleKey = tailscaleIpv4AddressKey(); - const magicDnsName = yield* Cache.get(magicDnsNameCache, tailscaleKey); + + const magicDnsName = yield* tailscale.readStatus().pipe( + Effect.map((status) => status.magicDnsName), + Effect.orElseSucceed(() => null), + ); const targets: Array = []; if (!sshdListening) { // Tailscale SSH needs no local sshd; tailscaled alone does not prove an SSH route. - if (magicDnsName !== null && (yield* Cache.get(sshEnabledCache, tailscaleKey))) { + if ( + magicDnsName !== null && + (yield* tailscale.readSshEnabled().pipe(Effect.orElseSucceed(() => false))) + ) { targets.push({ kind: "tailscale", host: magicDnsName, diff --git a/apps/server/src/environment/TailscaleStatusCache.ts b/apps/server/src/environment/TailscaleStatusCache.ts new file mode 100644 index 0000000000000..e4f78f45af0d1 --- /dev/null +++ b/apps/server/src/environment/TailscaleStatusCache.ts @@ -0,0 +1,73 @@ +import { + isTailscaleIpv4Address, + readTailscaleSshEnabled, + readTailscaleStatus, + type TailscaleStatus, +} from "@t3tools/tailscale"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as NodeOS from "node:os"; + +export class TailscaleStatusCache extends Context.Service< + TailscaleStatusCache, + { + readonly readStatus: () => Effect.Effect< + TailscaleStatus, + Effect.Error + >; + readonly readSshEnabled: () => Effect.Effect< + boolean, + Effect.Error + >; + } +>()("t3/environment/TailscaleStatusCache") {} + +const FAILED_READ_TTL = Duration.seconds(60); + +const tailnetAddressKey = (): string => + [ + ...new Set( + Object.values(NodeOS.networkInterfaces()) + .flatMap((addresses) => addresses ?? []) + .filter( + (address) => + !address.internal && + address.family === "IPv4" && + isTailscaleIpv4Address(address.address), + ) + .map((address) => address.address), + ), + ] + .toSorted() + .join(","); + +const timeToLive = (exit: Exit.Exit) => + Exit.isSuccess(exit) ? Duration.infinity : FAILED_READ_TTL; + +const make = Effect.gen(function* () { + // Each `tailscale` spawn can raise a macOS "access data from other apps" prompt on every + // client connect, so a successful read is kept until the tailnet addresses change, not timed out. + const statusCache = yield* Cache.makeWith((_key: string) => readTailscaleStatus, { + capacity: 1, + timeToLive, + }); + const sshEnabledCache = yield* Cache.makeWith((_key: string) => readTailscaleSshEnabled, { + capacity: 1, + timeToLive, + }); + + return TailscaleStatusCache.of({ + readStatus: Effect.fn("TailscaleStatusCache.readStatus")(function () { + return Cache.get(statusCache, tailnetAddressKey()); + }), + readSshEnabled: Effect.fn("TailscaleStatusCache.readSshEnabled")(function () { + return Cache.get(sshEnabledCache, tailnetAddressKey()); + }), + }); +}); + +export const layer = Layer.effect(TailscaleStatusCache, make); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f461a99186b31..5a91ea18663c8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -123,6 +123,7 @@ import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; +import * as TailscaleStatusCache from "./environment/TailscaleStatusCache.ts"; import * as AuthHttp from "./auth/http.ts"; import * as ReplayMarkers from "./auth/replayMarkers.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -689,6 +690,7 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(RemoteOpenTargets.layer), Layer.provideMerge(DirectEndpoints.layer), + Layer.provideMerge(TailscaleStatusCache.layer), Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provide(NetService.layer), ); From a568caaa7a2a7cfb5b5c4925a49a890f5601d20f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 19:15:25 +0000 Subject: [PATCH 4/7] fix(server): keep Tailscale reads only on macOS Never expiring a successful read meant Tailscale SSH toggles and machine renames went unnoticed until restart. Only macOS has the per-spawn prompt, so other platforms read fresh on every resolve again. Co-Authored-By: Claude Opus 5.5 --- .../src/environment/RemoteOpenTargets.test.ts | 36 ++++++++++++++++--- .../src/environment/TailscaleStatusCache.ts | 13 ++++--- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index b6e88aa47bbfc..4182a135d719f 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -91,10 +91,15 @@ const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => const layerTargets = ( sshd: { readonly ipv4: boolean; readonly ipv6: boolean }, spawner: Layer.Layer, + platform: NodeJS.Platform = "linux", ) => RemoteOpenTargets.layer.pipe( Layer.provide(layerNet(sshd)), - Layer.provide(TailscaleStatusCache.layer.pipe(Layer.provide(spawner))), + Layer.provide( + TailscaleStatusCache.layer.pipe( + Layer.provide(Layer.mergeAll(spawner, Layer.succeed(HostProcess.Platform, platform))), + ), + ), ); const resolveTargets = (input: { @@ -168,7 +173,7 @@ describe("RemoteOpenTargets", () => { }), ); - it.effect("keeps a Tailscale read until the tailnet addresses change", () => { + it.effect("on macOS keeps a Tailscale read until the tailnet addresses change", () => { const spawnedArgs: Array> = []; const spawned = () => ({ status: spawnedArgs.filter((args) => args[0] === "status").length, @@ -192,12 +197,16 @@ describe("RemoteOpenTargets", () => { Effect.provideService(HostProcess.Hostname, "bb-1"), Effect.provideService(HostProcess.Username, "t3-user"), Effect.provide( - layerTargets({ ipv4: false, ipv6: false }, layerSpawner(TAILSCALE_SSH, spawnedArgs)), + layerTargets( + { ipv4: false, ipv6: false }, + layerSpawner(TAILSCALE_SSH, spawnedArgs), + "darwin", + ), ), ); }); - it.effect("retries a failed Tailscale read after a minute", () => { + it.effect("on macOS retries a failed Tailscale read after a minute", () => { const spawnedArgs: Array> = []; const tailscale = { ...TAILSCALE_SSH, status: TAILSCALE_DOWN.status }; const statusReads = () => spawnedArgs.filter((args) => args[0] === "status").length; @@ -217,11 +226,28 @@ describe("RemoteOpenTargets", () => { Effect.provideService(HostProcess.Hostname, "bb-1"), Effect.provideService(HostProcess.Username, "t3-user"), Effect.provide( - layerTargets({ ipv4: false, ipv6: false }, layerSpawner(tailscale, spawnedArgs)), + layerTargets({ ipv4: false, ipv6: false }, layerSpawner(tailscale, spawnedArgs), "darwin"), ), ); }); + it.effect("outside macOS reads a Tailscale SSH change on the next resolve", () => { + const tailscale = { ...TAILSCALE_SSH }; + return Effect.gen(function* () { + const service = yield* RemoteOpenTargets.RemoteOpenTargets; + yield* useTailnetAddress("100.64.1.2"); + expect(yield* service.resolveTargets()).toEqual([ + { kind: "tailscale", host: "bb-1.tail1234.ts.net", username: "t3-user" }, + ]); + tailscale.prefs = prefsJson({ RunSSH: false, WantRunning: true }); + expect(yield* service.resolveTargets()).toEqual([]); + }).pipe( + Effect.provideService(HostProcess.Hostname, "bb-1"), + Effect.provideService(HostProcess.Username, "t3-user"), + Effect.provide(layerTargets({ ipv4: false, ipv6: false }, layerSpawner(tailscale))), + ); + }); + it.effect("accepts an sshd bound to IPv6 loopback only", () => Effect.gen(function* () { const targets = yield* resolveTargets({ diff --git a/apps/server/src/environment/TailscaleStatusCache.ts b/apps/server/src/environment/TailscaleStatusCache.ts index e4f78f45af0d1..a026751391fc5 100644 --- a/apps/server/src/environment/TailscaleStatusCache.ts +++ b/apps/server/src/environment/TailscaleStatusCache.ts @@ -1,3 +1,4 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; import { isTailscaleIpv4Address, readTailscaleSshEnabled, @@ -45,12 +46,14 @@ const tailnetAddressKey = (): string => .toSorted() .join(","); -const timeToLive = (exit: Exit.Exit) => - Exit.isSuccess(exit) ? Duration.infinity : FAILED_READ_TTL; - const make = Effect.gen(function* () { - // Each `tailscale` spawn can raise a macOS "access data from other apps" prompt on every - // client connect, so a successful read is kept until the tailnet addresses change, not timed out. + // Only macOS keeps reads: there each `tailscale` spawn can raise an "access data from other + // apps" prompt, so a success lives until the tailnet addresses change. Elsewhere every read is fresh. + const timeToLive = + (yield* HostProcess.Platform) === "darwin" + ? (exit: Exit.Exit) => + Exit.isSuccess(exit) ? Duration.infinity : FAILED_READ_TTL + : () => Duration.zero; const statusCache = yield* Cache.makeWith((_key: string) => readTailscaleStatus, { capacity: 1, timeToLive, From 7e7bc93998e6cc70afe9764edf3f566772c8e74a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:58:40 +0000 Subject: [PATCH 5/7] test(server): test the macOS Tailscale read policy at the cache Main's stale-while-revalidate cache in RemoteOpenTargets now sits above the Tailscale reads, so the policy tests move to TailscaleStatusCache itself. AI-assisted; verified with the tests and tools listed above. --- .../src/environment/RemoteOpenTargets.test.ts | 132 ++-------------- .../src/environment/RemoteOpenTargets.ts | 3 +- .../environment/TailscaleStatusCache.test.ts | 144 ++++++++++++++++++ 3 files changed, 161 insertions(+), 118 deletions(-) create mode 100644 apps/server/src/environment/TailscaleStatusCache.test.ts diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index 4182a135d719f..46122905fa86b 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -7,10 +7,8 @@ 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 TestClock from "effect/testing/TestClock"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import type * as NodeOS from "node:os"; import { afterEach, describe, expect, vi } from "vite-plus/test"; import * as RemoteOpenTargets from "./RemoteOpenTargets.ts"; @@ -23,25 +21,6 @@ afterEach(() => { const encoder = new TextEncoder(); -const nodeOs: typeof NodeOS = NodeModule.createRequire(import.meta.url)("node:os"); - -const useTailnetAddress = (address: string) => - Effect.sync(() => { - vi.spyOn(nodeOs, "networkInterfaces").mockReturnValue({ - tailscale0: [ - { - address, - netmask: "255.255.255.255", - family: "IPv4", - mac: "00:00:00:00:00:00", - internal: false, - cidr: `${address}/32`, - }, - ], - }); - NodeModule.syncBuiltinESMExports(); - }); - const TAILSCALE_STATUS_JSON = JSON.stringify({ Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, }); @@ -51,15 +30,11 @@ interface CommandResult { readonly stdout: string; } -const layerSpawner = ( - input: { readonly status: CommandResult; readonly prefs: CommandResult }, - spawnedArgs?: Array>, -) => +const layerSpawner = (input: { readonly status: CommandResult; readonly prefs: CommandResult }) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { const args = ChildProcess.isStandardCommand(command) ? command.args : []; - spawnedArgs?.push(args); const result = args[0] === "debug" ? input.prefs : input.status; return Effect.succeed( ChildProcessSpawner.makeHandle({ @@ -88,20 +63,6 @@ const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => findAvailablePort: (preferred) => Effect.succeed(preferred), }); -const layerTargets = ( - sshd: { readonly ipv4: boolean; readonly ipv6: boolean }, - spawner: Layer.Layer, - platform: NodeJS.Platform = "linux", -) => - RemoteOpenTargets.layer.pipe( - Layer.provide(layerNet(sshd)), - Layer.provide( - TailscaleStatusCache.layer.pipe( - Layer.provide(Layer.mergeAll(spawner, Layer.succeed(HostProcess.Platform, platform))), - ), - ), - ); - const resolveTargets = (input: { readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean }; readonly tailscale: { readonly status: CommandResult; readonly prefs: CommandResult }; @@ -112,7 +73,21 @@ const resolveTargets = (input: { service.resolveTargets(), ).pipe( Effect.provideService(HostProcess.Hostname, input.hostname), - Effect.provide(layerTargets(input.sshd, layerSpawner(input.tailscale))), + Effect.provide( + RemoteOpenTargets.layer.pipe( + Layer.provide(layerNet(input.sshd)), + Layer.provide( + TailscaleStatusCache.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerSpawner(input.tailscale), + Layer.succeed(HostProcess.Platform, "linux"), + ), + ), + ), + ), + ), + ), ); return input.username === undefined @@ -173,81 +148,6 @@ describe("RemoteOpenTargets", () => { }), ); - it.effect("on macOS keeps a Tailscale read until the tailnet addresses change", () => { - const spawnedArgs: Array> = []; - const spawned = () => ({ - status: spawnedArgs.filter((args) => args[0] === "status").length, - debugPrefs: spawnedArgs.filter((args) => args[0] === "debug").length, - }); - return Effect.gen(function* () { - const service = yield* RemoteOpenTargets.RemoteOpenTargets; - yield* useTailnetAddress("100.64.1.2"); - for (let connect = 0; connect < 3; connect++) { - expect(yield* service.resolveTargets()).toEqual([ - { kind: "tailscale", host: "bb-1.tail1234.ts.net", username: "t3-user" }, - ]); - yield* TestClock.adjust("2 minutes"); - } - expect(spawned()).toEqual({ status: 1, debugPrefs: 1 }); - - yield* useTailnetAddress("100.64.1.3"); - yield* service.resolveTargets(); - expect(spawned()).toEqual({ status: 2, debugPrefs: 2 }); - }).pipe( - Effect.provideService(HostProcess.Hostname, "bb-1"), - Effect.provideService(HostProcess.Username, "t3-user"), - Effect.provide( - layerTargets( - { ipv4: false, ipv6: false }, - layerSpawner(TAILSCALE_SSH, spawnedArgs), - "darwin", - ), - ), - ); - }); - - it.effect("on macOS retries a failed Tailscale read after a minute", () => { - const spawnedArgs: Array> = []; - const tailscale = { ...TAILSCALE_SSH, status: TAILSCALE_DOWN.status }; - const statusReads = () => spawnedArgs.filter((args) => args[0] === "status").length; - return Effect.gen(function* () { - const service = yield* RemoteOpenTargets.RemoteOpenTargets; - expect(yield* service.resolveTargets()).toEqual([]); - tailscale.status = STATUS_UP; - expect(yield* service.resolveTargets()).toEqual([]); - expect(statusReads()).toBe(1); - - yield* TestClock.adjust("61 seconds"); - expect(yield* service.resolveTargets()).toEqual([ - { kind: "tailscale", host: "bb-1.tail1234.ts.net", username: "t3-user" }, - ]); - expect(statusReads()).toBe(2); - }).pipe( - Effect.provideService(HostProcess.Hostname, "bb-1"), - Effect.provideService(HostProcess.Username, "t3-user"), - Effect.provide( - layerTargets({ ipv4: false, ipv6: false }, layerSpawner(tailscale, spawnedArgs), "darwin"), - ), - ); - }); - - it.effect("outside macOS reads a Tailscale SSH change on the next resolve", () => { - const tailscale = { ...TAILSCALE_SSH }; - return Effect.gen(function* () { - const service = yield* RemoteOpenTargets.RemoteOpenTargets; - yield* useTailnetAddress("100.64.1.2"); - expect(yield* service.resolveTargets()).toEqual([ - { kind: "tailscale", host: "bb-1.tail1234.ts.net", username: "t3-user" }, - ]); - tailscale.prefs = prefsJson({ RunSSH: false, WantRunning: true }); - expect(yield* service.resolveTargets()).toEqual([]); - }).pipe( - Effect.provideService(HostProcess.Hostname, "bb-1"), - Effect.provideService(HostProcess.Username, "t3-user"), - Effect.provide(layerTargets({ ipv4: false, ipv6: false }, layerSpawner(tailscale))), - ); - }); - 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 fd7304189a35a..4e204bf210cc4 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -5,9 +5,8 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as TailscaleStatusCache from "./TailscaleStatusCache.ts"; - import { makeStaleWhileRevalidate } from "./staleWhileRevalidate.ts"; +import * as TailscaleStatusCache from "./TailscaleStatusCache.ts"; const SSH_PORT = 22; const TARGETS_CACHE_TTL = "60 seconds"; diff --git a/apps/server/src/environment/TailscaleStatusCache.test.ts b/apps/server/src/environment/TailscaleStatusCache.test.ts new file mode 100644 index 0000000000000..423b65c7ecddd --- /dev/null +++ b/apps/server/src/environment/TailscaleStatusCache.test.ts @@ -0,0 +1,144 @@ +// @effect-diagnostics nodeBuiltinImport:off -- replace the native OS lookup and sync its ESM bindings. +import * as NodeModule from "node:module"; +import { 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 Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import type * as NodeOS from "node:os"; +import { afterEach, describe, expect, vi } from "vite-plus/test"; + +import * as TailscaleStatusCache from "./TailscaleStatusCache.ts"; + +afterEach(() => { + vi.restoreAllMocks(); + NodeModule.syncBuiltinESMExports(); +}); + +const encoder = new TextEncoder(); +const nodeOs: typeof NodeOS = NodeModule.createRequire(import.meta.url)("node:os"); + +const useTailnetAddress = (address: string) => + Effect.sync(() => { + vi.spyOn(nodeOs, "networkInterfaces").mockReturnValue({ + tailscale0: [ + { + address, + netmask: "255.255.255.255", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: false, + cidr: `${address}/32`, + }, + ], + }); + NodeModule.syncBuiltinESMExports(); + }); + +interface CommandResult { + readonly exitCode: number; + readonly stdout: string; +} + +const STATUS_UP: CommandResult = { + exitCode: 0, + stdout: JSON.stringify({ + Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, + }), +}; +const STATUS_DOWN: CommandResult = { exitCode: 1, stdout: "" }; +const prefs = (RunSSH: boolean): CommandResult => ({ + exitCode: 0, + stdout: JSON.stringify({ RunSSH, WantRunning: true }), +}); + +const makeTailscale = (platform: NodeJS.Platform, initial: { status: CommandResult }) => { + const tailscale = { status: initial.status, prefs: prefs(true) }; + const spawned = { status: 0, prefs: 0 }; + const spawner = ChildProcessSpawner.make((command) => { + const args = ChildProcess.isStandardCommand(command) ? command.args : []; + const isPrefs = args[0] === "debug"; + spawned[isPrefs ? "prefs" : "status"]++; + const result = isPrefs ? tailscale.prefs : tailscale.status; + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + const layer = TailscaleStatusCache.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HostProcess.Platform, platform), + ), + ), + ); + return { tailscale, spawned, layer }; +}; + +const readBoth = Effect.gen(function* () { + const cache = yield* TailscaleStatusCache.TailscaleStatusCache; + const magicDnsName = yield* cache.readStatus().pipe( + Effect.map((status) => status.magicDnsName), + Effect.orElseSucceed(() => null), + ); + const sshEnabled = yield* cache.readSshEnabled(); + return { magicDnsName, sshEnabled }; +}); + +describe("TailscaleStatusCache", () => { + it.effect("on macOS keeps a read until the tailnet addresses change", () => { + const { spawned, layer } = makeTailscale("darwin", { status: STATUS_UP }); + return Effect.gen(function* () { + yield* useTailnetAddress("100.64.1.2"); + for (let connect = 0; connect < 3; connect++) { + expect(yield* readBoth).toEqual({ magicDnsName: "bb-1.tail1234.ts.net", sshEnabled: true }); + yield* TestClock.adjust("2 minutes"); + } + expect(spawned).toEqual({ status: 1, prefs: 1 }); + + yield* useTailnetAddress("100.64.1.3"); + yield* readBoth; + expect(spawned).toEqual({ status: 2, prefs: 2 }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("on macOS retries a failed read after a minute", () => { + const { tailscale, spawned, layer } = makeTailscale("darwin", { status: STATUS_DOWN }); + return Effect.gen(function* () { + expect((yield* readBoth).magicDnsName).toBeNull(); + tailscale.status = STATUS_UP; + expect((yield* readBoth).magicDnsName).toBeNull(); + expect(spawned.status).toBe(1); + + yield* TestClock.adjust("61 seconds"); + expect((yield* readBoth).magicDnsName).toBe("bb-1.tail1234.ts.net"); + expect(spawned.status).toBe(2); + }).pipe(Effect.provide(layer)); + }); + + it.effect("outside macOS reads a Tailscale SSH change on the next read", () => { + const { tailscale, layer } = makeTailscale("linux", { status: STATUS_UP }); + return Effect.gen(function* () { + yield* useTailnetAddress("100.64.1.2"); + expect((yield* readBoth).sshEnabled).toBe(true); + tailscale.prefs = prefs(false); + expect((yield* readBoth).sshEnabled).toBe(false); + }).pipe(Effect.provide(layer)); + }); +}); From c6ada0ff4cf85318475d9351e90739d25e6eecbd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:16:36 +0000 Subject: [PATCH 6/7] fix(server): macOS remote-open discovery no longer runs the tailscale CLI Caching `tailscale status` only made the macOS data-access prompts rarer: address changes and failed reads still spawned the CLI, and a cached status kept a renamed node's old MagicDNS name. Drop the cache. On macOS, connect-time discovery now reads the tailnet name with a system-resolver reverse lookup of the tailnet IPv4 address and skips the Tailscale SSH prefs read. Serve endpoint discovery keeps main's fresh CLI read, since Serve is opt-in and its setup already runs the CLI. AI-assisted; verified with the tests and tools listed above. --- .../src/environment/DirectEndpoints.test.ts | 9 +- .../server/src/environment/DirectEndpoints.ts | 13 +- .../src/environment/RemoteOpenTargets.test.ts | 64 ++++++-- .../src/environment/RemoteOpenTargets.ts | 55 ++++++- .../environment/TailscaleStatusCache.test.ts | 144 ------------------ .../src/environment/TailscaleStatusCache.ts | 76 --------- apps/server/src/server.ts | 2 - 7 files changed, 112 insertions(+), 251 deletions(-) delete mode 100644 apps/server/src/environment/TailscaleStatusCache.test.ts delete mode 100644 apps/server/src/environment/TailscaleStatusCache.ts diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 149358e3da2cc..def0434140c32 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -16,7 +16,6 @@ import { describe, expect, it } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import * as DirectEndpoints from "./DirectEndpoints.ts"; import { resolveBoundEndpoints } from "./DirectEndpoints.ts"; -import * as TailscaleStatusCache from "./TailscaleStatusCache.ts"; const INTERFACES: ReturnType = { lo0: [ @@ -279,11 +278,7 @@ const resolveWithServe = (probeStatus: number) => Effect.provide( DirectEndpoints.layer.pipe( Layer.provide( - Layer.mergeAll( - layerServeConfig(), - TailscaleStatusCache.layer.pipe(Layer.provide(layerTailscaleUp)), - layerServeProbe(probeStatus), - ), + Layer.mergeAll(layerServeConfig(), layerTailscaleUp, layerServeProbe(probeStatus)), ), ), ), @@ -311,7 +306,7 @@ describe("DirectEndpoints Tailscale Serve", () => { Effect.provide( Layer.mergeAll( layerServeConfig("192.168.1.10"), - TailscaleStatusCache.layer.pipe(Layer.provide(layerTailscaleUp)), + layerTailscaleUp, Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 66ca25748a5e1..47e9772e20fe1 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -1,5 +1,9 @@ import type { ServerDirectEndpoint } from "@t3tools/contracts"; -import { buildTailscaleHttpsBaseUrl, probeTailscaleHttpsEndpoint } from "@t3tools/tailscale"; +import { + buildTailscaleHttpsBaseUrl, + probeTailscaleHttpsEndpoint, + readTailscaleStatus, +} from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -7,6 +11,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { HttpClient } from "effect/http"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; @@ -21,7 +26,6 @@ import { isWildcardHost, VIRTUAL_INTERFACE, } from "../startupAccess.ts"; -import * as TailscaleStatusCache from "./TailscaleStatusCache.ts"; type NetworkInterfacesMap = ReturnType; @@ -115,7 +119,7 @@ const probeCloudMetadata = Effect.callback((resume) => { /** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - const tailscale = yield* TailscaleStatusCache.TailscaleStatusCache; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const platform = yield* HostProcess.Platform; // Only the service default listens on every interface without being asked, @@ -145,7 +149,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* tailscale.readStatus().pipe( + const servedUrl = yield* readTailscaleStatus.pipe( Effect.map((status) => status.magicDnsName), Effect.flatMap((magicDnsName) => { if (magicDnsName === null) return Effect.succeed(null); @@ -160,6 +164,7 @@ export const make = Effect.gen(function* () { Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT), Effect.map(Option.flatMap(Option.fromNullishOr)), Effect.orElseSucceed(() => Option.none()), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), ); yield* Ref.set(servedEndpoint, servedUrl); diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index 46122905fa86b..8060c25cbac0d 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -9,10 +9,11 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import type * as NodeDns from "node:dns"; +import type * as NodeOS from "node:os"; import { afterEach, describe, expect, vi } from "vite-plus/test"; import * as RemoteOpenTargets from "./RemoteOpenTargets.ts"; -import * as TailscaleStatusCache from "./TailscaleStatusCache.ts"; afterEach(() => { vi.restoreAllMocks(); @@ -20,6 +21,9 @@ afterEach(() => { }); const encoder = new TextEncoder(); +const requireBuiltin = NodeModule.createRequire(import.meta.url); +const nodeOs: typeof NodeOS = requireBuiltin("node:os"); +const nodeDns: typeof NodeDns = requireBuiltin("node:dns"); const TAILSCALE_STATUS_JSON = JSON.stringify({ Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, @@ -30,11 +34,15 @@ interface CommandResult { readonly stdout: string; } -const layerSpawner = (input: { readonly status: CommandResult; readonly prefs: CommandResult }) => +const layerSpawner = ( + input: { readonly status: CommandResult; readonly prefs: CommandResult }, + spawned?: Array, +) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { const args = ChildProcess.isStandardCommand(command) ? command.args : []; + spawned?.push(args); const result = args[0] === "debug" ? input.prefs : input.status; return Effect.succeed( ChildProcessSpawner.makeHandle({ @@ -68,6 +76,8 @@ const resolveTargets = (input: { readonly tailscale: { readonly status: CommandResult; readonly prefs: CommandResult }; readonly hostname: string; readonly username?: string | null; + readonly platform?: NodeJS.Platform; + readonly spawned?: Array; }) => { const resolve = Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets(), @@ -75,19 +85,12 @@ const resolveTargets = (input: { Effect.provideService(HostProcess.Hostname, input.hostname), Effect.provide( RemoteOpenTargets.layer.pipe( - Layer.provide(layerNet(input.sshd)), Layer.provide( - TailscaleStatusCache.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerSpawner(input.tailscale), - Layer.succeed(HostProcess.Platform, "linux"), - ), - ), - ), + Layer.mergeAll(layerNet(input.sshd), layerSpawner(input.tailscale, input.spawned)), ), ), ), + Effect.provideService(HostProcess.Platform, input.platform ?? "linux"), ); return input.username === undefined @@ -148,6 +151,45 @@ describe("RemoteOpenTargets", () => { }), ); + it.effect("on macOS reads the tailnet name from the system resolver, never the CLI", () => + Effect.gen(function* () { + vi.spyOn(nodeOs, "networkInterfaces").mockReturnValue({ + 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", + }, + ], + }); + NodeModule.syncBuiltinESMExports(); + const lookupService = vi + .spyOn(nodeDns.promises, "lookupService") + .mockResolvedValue({ hostname: "renamed.tail1234.ts.net.", service: "0" }); + const spawned: Array = []; + const resolve = (sshd: { readonly ipv4: boolean; readonly ipv6: boolean }) => + resolveTargets({ + sshd, + tailscale: TAILSCALE_SSH, + hostname: "bb-1", + username: "t3-user", + platform: "darwin", + spawned, + }); + + expect(yield* resolve({ ipv4: true, ipv6: false })).toEqual([ + { kind: "tailscale", host: "renamed.tail1234.ts.net", username: "t3-user" }, + { kind: "mdns", host: "bb-1.local", username: "t3-user" }, + ]); + expect(yield* resolve({ ipv4: false, ipv6: false })).toEqual([]); + expect(lookupService).toHaveBeenCalledWith("100.64.1.2", 0); + expect(spawned).toEqual([]); + }), + ); + 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 4e204bf210cc4..03bbba03f4f2d 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -1,15 +1,45 @@ import { type RemoteOpenTarget } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; +import { + isTailscaleIpv4Address, + readTailscaleSshEnabled, + readTailscaleStatus, +} from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as NodeDns from "node:dns"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; import { makeStaleWhileRevalidate } from "./staleWhileRevalidate.ts"; -import * as TailscaleStatusCache from "./TailscaleStatusCache.ts"; const SSH_PORT = 22; const TARGETS_CACHE_TTL = "60 seconds"; +const TAILNET_NAME_LOOKUP_TIMEOUT = "1500 millis"; + +// MagicDNS answers reverse lookups for tailnet addresses through the system resolver. +const lookupTailnetName = Effect.gen(function* () { + const address = Object.values(NodeOS.networkInterfaces()) + .flatMap((addresses) => addresses ?? []) + .find( + (entry) => + !entry.internal && entry.family === "IPv4" && isTailscaleIpv4Address(entry.address), + )?.address; + if (address === undefined) return null; + return yield* Effect.promise(() => + NodeDns.promises.lookupService(address, 0).then( + ({ hostname }) => { + const name = hostname.trim().replace(/\.$/u, ""); + return name.includes(".") && NodeNet.isIP(name) === 0 ? name : null; + }, + () => null, + ), + ).pipe(Effect.timeoutOption(TAILNET_NAME_LOOKUP_TIMEOUT), Effect.map(Option.getOrNull)); +}); export class RemoteOpenTargets extends Context.Service< RemoteOpenTargets, @@ -20,8 +50,11 @@ export class RemoteOpenTargets extends Context.Service< /** @public */ export const make = Effect.gen(function* () { - const tailscale = yield* TailscaleStatusCache.TailscaleStatusCache; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const net = yield* NetService.NetService; + // On macOS the CLI runs from the Tailscale app bundle, and macOS can ask the user to let + // T3 Code access another app's data on every spawn, so connect-time discovery avoids it. + const avoidTailscaleCli = (yield* HostProcess.Platform) === "darwin"; const discoverTargets = Effect.gen(function* () { // Check both loopback families: sshd can be bound IPv6-only. @@ -32,18 +65,26 @@ export const make = Effect.gen(function* () { ); const username = yield* HostProcess.Username; - const magicDnsName = yield* tailscale.readStatus().pipe( - Effect.map((status) => status.magicDnsName), - Effect.orElseSucceed(() => null), - ); + const magicDnsName = avoidTailscaleCli + ? yield* lookupTailnetName + : yield* readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.orElseSucceed(() => null), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); const targets: Array = []; if (!sshdListening) { // Tailscale SSH needs no local sshd; tailscaled alone does not prove an SSH route. + // Only `tailscale debug prefs` proves it, so macOS skips this target. if ( magicDnsName !== null && - (yield* tailscale.readSshEnabled().pipe(Effect.orElseSucceed(() => false))) + !avoidTailscaleCli && + (yield* readTailscaleSshEnabled.pipe( + Effect.orElseSucceed(() => false), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + )) ) { targets.push({ kind: "tailscale", diff --git a/apps/server/src/environment/TailscaleStatusCache.test.ts b/apps/server/src/environment/TailscaleStatusCache.test.ts deleted file mode 100644 index 423b65c7ecddd..0000000000000 --- a/apps/server/src/environment/TailscaleStatusCache.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -- replace the native OS lookup and sync its ESM bindings. -import * as NodeModule from "node:module"; -import { 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 Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import * as ChildProcess from "effect/process/ChildProcess"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import type * as NodeOS from "node:os"; -import { afterEach, describe, expect, vi } from "vite-plus/test"; - -import * as TailscaleStatusCache from "./TailscaleStatusCache.ts"; - -afterEach(() => { - vi.restoreAllMocks(); - NodeModule.syncBuiltinESMExports(); -}); - -const encoder = new TextEncoder(); -const nodeOs: typeof NodeOS = NodeModule.createRequire(import.meta.url)("node:os"); - -const useTailnetAddress = (address: string) => - Effect.sync(() => { - vi.spyOn(nodeOs, "networkInterfaces").mockReturnValue({ - tailscale0: [ - { - address, - netmask: "255.255.255.255", - family: "IPv4", - mac: "00:00:00:00:00:00", - internal: false, - cidr: `${address}/32`, - }, - ], - }); - NodeModule.syncBuiltinESMExports(); - }); - -interface CommandResult { - readonly exitCode: number; - readonly stdout: string; -} - -const STATUS_UP: CommandResult = { - exitCode: 0, - stdout: JSON.stringify({ - Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, - }), -}; -const STATUS_DOWN: CommandResult = { exitCode: 1, stdout: "" }; -const prefs = (RunSSH: boolean): CommandResult => ({ - exitCode: 0, - stdout: JSON.stringify({ RunSSH, WantRunning: true }), -}); - -const makeTailscale = (platform: NodeJS.Platform, initial: { status: CommandResult }) => { - const tailscale = { status: initial.status, prefs: prefs(true) }; - const spawned = { status: 0, prefs: 0 }; - const spawner = ChildProcessSpawner.make((command) => { - const args = ChildProcess.isStandardCommand(command) ? command.args : []; - const isPrefs = args[0] === "debug"; - spawned[isPrefs ? "prefs" : "status"]++; - const result = isPrefs ? tailscale.prefs : tailscale.status; - return Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.make(encoder.encode(result.stdout)), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ); - }); - const layer = TailscaleStatusCache.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - Layer.succeed(HostProcess.Platform, platform), - ), - ), - ); - return { tailscale, spawned, layer }; -}; - -const readBoth = Effect.gen(function* () { - const cache = yield* TailscaleStatusCache.TailscaleStatusCache; - const magicDnsName = yield* cache.readStatus().pipe( - Effect.map((status) => status.magicDnsName), - Effect.orElseSucceed(() => null), - ); - const sshEnabled = yield* cache.readSshEnabled(); - return { magicDnsName, sshEnabled }; -}); - -describe("TailscaleStatusCache", () => { - it.effect("on macOS keeps a read until the tailnet addresses change", () => { - const { spawned, layer } = makeTailscale("darwin", { status: STATUS_UP }); - return Effect.gen(function* () { - yield* useTailnetAddress("100.64.1.2"); - for (let connect = 0; connect < 3; connect++) { - expect(yield* readBoth).toEqual({ magicDnsName: "bb-1.tail1234.ts.net", sshEnabled: true }); - yield* TestClock.adjust("2 minutes"); - } - expect(spawned).toEqual({ status: 1, prefs: 1 }); - - yield* useTailnetAddress("100.64.1.3"); - yield* readBoth; - expect(spawned).toEqual({ status: 2, prefs: 2 }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("on macOS retries a failed read after a minute", () => { - const { tailscale, spawned, layer } = makeTailscale("darwin", { status: STATUS_DOWN }); - return Effect.gen(function* () { - expect((yield* readBoth).magicDnsName).toBeNull(); - tailscale.status = STATUS_UP; - expect((yield* readBoth).magicDnsName).toBeNull(); - expect(spawned.status).toBe(1); - - yield* TestClock.adjust("61 seconds"); - expect((yield* readBoth).magicDnsName).toBe("bb-1.tail1234.ts.net"); - expect(spawned.status).toBe(2); - }).pipe(Effect.provide(layer)); - }); - - it.effect("outside macOS reads a Tailscale SSH change on the next read", () => { - const { tailscale, layer } = makeTailscale("linux", { status: STATUS_UP }); - return Effect.gen(function* () { - yield* useTailnetAddress("100.64.1.2"); - expect((yield* readBoth).sshEnabled).toBe(true); - tailscale.prefs = prefs(false); - expect((yield* readBoth).sshEnabled).toBe(false); - }).pipe(Effect.provide(layer)); - }); -}); diff --git a/apps/server/src/environment/TailscaleStatusCache.ts b/apps/server/src/environment/TailscaleStatusCache.ts deleted file mode 100644 index a026751391fc5..0000000000000 --- a/apps/server/src/environment/TailscaleStatusCache.ts +++ /dev/null @@ -1,76 +0,0 @@ -import * as HostProcess from "@t3tools/shared/HostProcess"; -import { - isTailscaleIpv4Address, - readTailscaleSshEnabled, - readTailscaleStatus, - type TailscaleStatus, -} from "@t3tools/tailscale"; -import * as Cache from "effect/Cache"; -import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as NodeOS from "node:os"; - -export class TailscaleStatusCache extends Context.Service< - TailscaleStatusCache, - { - readonly readStatus: () => Effect.Effect< - TailscaleStatus, - Effect.Error - >; - readonly readSshEnabled: () => Effect.Effect< - boolean, - Effect.Error - >; - } ->()("t3/environment/TailscaleStatusCache") {} - -const FAILED_READ_TTL = Duration.seconds(60); - -const tailnetAddressKey = (): string => - [ - ...new Set( - Object.values(NodeOS.networkInterfaces()) - .flatMap((addresses) => addresses ?? []) - .filter( - (address) => - !address.internal && - address.family === "IPv4" && - isTailscaleIpv4Address(address.address), - ) - .map((address) => address.address), - ), - ] - .toSorted() - .join(","); - -const make = Effect.gen(function* () { - // Only macOS keeps reads: there each `tailscale` spawn can raise an "access data from other - // apps" prompt, so a success lives until the tailnet addresses change. Elsewhere every read is fresh. - const timeToLive = - (yield* HostProcess.Platform) === "darwin" - ? (exit: Exit.Exit) => - Exit.isSuccess(exit) ? Duration.infinity : FAILED_READ_TTL - : () => Duration.zero; - const statusCache = yield* Cache.makeWith((_key: string) => readTailscaleStatus, { - capacity: 1, - timeToLive, - }); - const sshEnabledCache = yield* Cache.makeWith((_key: string) => readTailscaleSshEnabled, { - capacity: 1, - timeToLive, - }); - - return TailscaleStatusCache.of({ - readStatus: Effect.fn("TailscaleStatusCache.readStatus")(function () { - return Cache.get(statusCache, tailnetAddressKey()); - }), - readSshEnabled: Effect.fn("TailscaleStatusCache.readSshEnabled")(function () { - return Cache.get(sshEnabledCache, tailnetAddressKey()); - }), - }); -}); - -export const layer = Layer.effect(TailscaleStatusCache, make); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9eb9ac7f3c263..47190f60dcf51 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -142,7 +142,6 @@ import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; -import * as TailscaleStatusCache from "./environment/TailscaleStatusCache.ts"; import * as AuthHttp from "./auth/http.ts"; import * as ReplayMarkers from "./auth/replayMarkers.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -767,7 +766,6 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(RemoteOpenTargets.layer), Layer.provideMerge(DirectEndpoints.layer), - Layer.provideMerge(TailscaleStatusCache.layer), Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provide(NetService.layer), ); From 673d866aad7abef48c01f9fcaa3f52df5ab91bbb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:32:50 +0000 Subject: [PATCH 7/7] fix(server): keep macOS tailnet targets the resolver path dropped Skip the tailscale CLI only for the macOS Tailscale apps, whose CLI runs from the app bundle. Open-source tailscaled creates /var/run/tailscaled.socket, and there the CLI reads work as before, including the Tailscale SSH target. When the reverse lookup has no answer, for example with accept-dns=false, advertise the tailnet IPv4 address instead of dropping the target. AI-assisted; verified with the tests and tools listed above. --- .../src/environment/RemoteOpenTargets.test.ts | 77 ++++++++++++++----- .../src/environment/RemoteOpenTargets.ts | 41 ++++++---- 2 files changed, 86 insertions(+), 32 deletions(-) diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index 8060c25cbac0d..c758f8918c216 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -4,6 +4,7 @@ import { it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; 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"; @@ -25,6 +26,23 @@ const requireBuiltin = NodeModule.createRequire(import.meta.url); const nodeOs: typeof NodeOS = requireBuiltin("node:os"); const nodeDns: typeof NodeDns = requireBuiltin("node:dns"); +const useMacTailnet = () => { + vi.spyOn(nodeOs, "networkInterfaces").mockReturnValue({ + 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", + }, + ], + }); + NodeModule.syncBuiltinESMExports(); + return vi.spyOn(nodeDns.promises, "lookupService"); +}; + const TAILSCALE_STATUS_JSON = JSON.stringify({ Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, }); @@ -78,6 +96,7 @@ const resolveTargets = (input: { readonly username?: string | null; readonly platform?: NodeJS.Platform; readonly spawned?: Array; + readonly openSourceTailscaled?: boolean; }) => { const resolve = Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets(), @@ -86,7 +105,16 @@ const resolveTargets = (input: { Effect.provide( RemoteOpenTargets.layer.pipe( Layer.provide( - Layer.mergeAll(layerNet(input.sshd), layerSpawner(input.tailscale, input.spawned)), + Layer.mergeAll( + layerNet(input.sshd), + layerSpawner(input.tailscale, input.spawned), + FileSystem.layerNoop({ + exists: (path) => + Effect.succeed( + path === "/var/run/tailscaled.socket" && input.openSourceTailscaled === true, + ), + }), + ), ), ), ), @@ -151,24 +179,12 @@ describe("RemoteOpenTargets", () => { }), ); - it.effect("on macOS reads the tailnet name from the system resolver, never the CLI", () => + it.effect("with a macOS Tailscale app, resolves the tailnet host without the CLI", () => Effect.gen(function* () { - vi.spyOn(nodeOs, "networkInterfaces").mockReturnValue({ - 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", - }, - ], + const lookupService = useMacTailnet().mockResolvedValueOnce({ + hostname: "renamed.tail1234.ts.net.", + service: "0", }); - NodeModule.syncBuiltinESMExports(); - const lookupService = vi - .spyOn(nodeDns.promises, "lookupService") - .mockResolvedValue({ hostname: "renamed.tail1234.ts.net.", service: "0" }); const spawned: Array = []; const resolve = (sshd: { readonly ipv4: boolean; readonly ipv6: boolean }) => resolveTargets({ @@ -184,12 +200,37 @@ describe("RemoteOpenTargets", () => { { kind: "tailscale", host: "renamed.tail1234.ts.net", username: "t3-user" }, { kind: "mdns", host: "bb-1.local", username: "t3-user" }, ]); - expect(yield* resolve({ ipv4: false, ipv6: false })).toEqual([]); + // accept-dns=false: no reverse answer, but the tailnet address still reaches this host. + lookupService.mockRejectedValueOnce( + Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" }), + ); + expect(yield* resolve({ ipv4: true, ipv6: false })).toEqual([ + { kind: "tailscale", host: "100.64.1.2", username: "t3-user" }, + { kind: "mdns", host: "bb-1.local", username: "t3-user" }, + ]); expect(lookupService).toHaveBeenCalledWith("100.64.1.2", 0); expect(spawned).toEqual([]); }), ); + it.effect("with open-source tailscaled on macOS, keeps the Tailscale SSH target", () => + Effect.gen(function* () { + const lookupService = useMacTailnet(); + const targets = yield* resolveTargets({ + sshd: { ipv4: false, ipv6: false }, + tailscale: TAILSCALE_SSH, + hostname: "bb-1", + username: "t3-user", + platform: "darwin", + openSourceTailscaled: true, + }); + expect(targets).toEqual([ + { kind: "tailscale", host: "bb-1.tail1234.ts.net", username: "t3-user" }, + ]); + expect(lookupService).not.toHaveBeenCalled(); + }), + ); + 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 03bbba03f4f2d..b620c1352656f 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -20,9 +21,12 @@ import { makeStaleWhileRevalidate } from "./staleWhileRevalidate.ts"; const SSH_PORT = 22; const TARGETS_CACHE_TTL = "60 seconds"; const TAILNET_NAME_LOOKUP_TIMEOUT = "1500 millis"; +// Only the open-source tailscaled creates this; its CLI is not in an app bundle. +const OPEN_SOURCE_TAILSCALED_SOCKET = "/var/run/tailscaled.socket"; // MagicDNS answers reverse lookups for tailnet addresses through the system resolver. -const lookupTailnetName = Effect.gen(function* () { +// Without an answer (e.g. accept-dns=false), the tailnet address is still reachable. +const lookupTailnetHost = Effect.gen(function* () { const address = Object.values(NodeOS.networkInterfaces()) .flatMap((addresses) => addresses ?? []) .find( @@ -34,11 +38,14 @@ const lookupTailnetName = Effect.gen(function* () { NodeDns.promises.lookupService(address, 0).then( ({ hostname }) => { const name = hostname.trim().replace(/\.$/u, ""); - return name.includes(".") && NodeNet.isIP(name) === 0 ? name : null; + return name.includes(".") && NodeNet.isIP(name) === 0 ? name : address; }, - () => null, + () => address, ), - ).pipe(Effect.timeoutOption(TAILNET_NAME_LOOKUP_TIMEOUT), Effect.map(Option.getOrNull)); + ).pipe( + Effect.timeoutOption(TAILNET_NAME_LOOKUP_TIMEOUT), + Effect.map(Option.getOrElse(() => address)), + ); }); export class RemoteOpenTargets extends Context.Service< @@ -52,9 +59,8 @@ export class RemoteOpenTargets extends Context.Service< export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const net = yield* NetService.NetService; - // On macOS the CLI runs from the Tailscale app bundle, and macOS can ask the user to let - // T3 Code access another app's data on every spawn, so connect-time discovery avoids it. - const avoidTailscaleCli = (yield* HostProcess.Platform) === "darwin"; + const fileSystem = yield* FileSystem.FileSystem; + const isMacOS = (yield* HostProcess.Platform) === "darwin"; const discoverTargets = Effect.gen(function* () { // Check both loopback families: sshd can be bound IPv6-only. @@ -65,8 +71,15 @@ export const make = Effect.gen(function* () { ); const username = yield* HostProcess.Username; - const magicDnsName = avoidTailscaleCli - ? yield* lookupTailnetName + // The macOS Tailscale apps run the CLI from their app bundle, and macOS can ask the user + // to let T3 Code access another app's data on every spawn, so connect-time discovery avoids it. + const avoidTailscaleCli = + isMacOS && + !(yield* fileSystem + .exists(OPEN_SOURCE_TAILSCALED_SOCKET) + .pipe(Effect.orElseSucceed(() => false))); + const tailnetHost = avoidTailscaleCli + ? yield* lookupTailnetHost : yield* readTailscaleStatus.pipe( Effect.map((status) => status.magicDnsName), Effect.orElseSucceed(() => null), @@ -77,9 +90,9 @@ export const make = Effect.gen(function* () { if (!sshdListening) { // Tailscale SSH needs no local sshd; tailscaled alone does not prove an SSH route. - // Only `tailscale debug prefs` proves it, so macOS skips this target. + // Only `tailscale debug prefs` proves it; the macOS apps cannot run an SSH server. if ( - magicDnsName !== null && + tailnetHost !== null && !avoidTailscaleCli && (yield* readTailscaleSshEnabled.pipe( Effect.orElseSucceed(() => false), @@ -88,17 +101,17 @@ export const make = Effect.gen(function* () { ) { 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 }), }); }