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