diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index 3e66d5f707a0d..c758f8918c216 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -4,11 +4,14 @@ 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"; 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"; @@ -19,6 +22,26 @@ 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 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"] }, @@ -29,11 +52,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({ @@ -67,6 +94,9 @@ 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; + readonly openSourceTailscaled?: boolean; }) => { const resolve = Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets(), @@ -74,9 +104,21 @@ const resolveTargets = (input: { Effect.provideService(HostProcess.Hostname, input.hostname), Effect.provide( RemoteOpenTargets.layer.pipe( - Layer.provide(Layer.mergeAll(layerNet(input.sshd), layerSpawner(input.tailscale))), + Layer.provide( + Layer.mergeAll( + layerNet(input.sshd), + layerSpawner(input.tailscale, input.spawned), + FileSystem.layerNoop({ + exists: (path) => + Effect.succeed( + path === "/var/run/tailscaled.socket" && input.openSourceTailscaled === true, + ), + }), + ), + ), ), ), + Effect.provideService(HostProcess.Platform, input.platform ?? "linux"), ); return input.username === undefined @@ -137,6 +179,58 @@ describe("RemoteOpenTargets", () => { }), ); + it.effect("with a macOS Tailscale app, resolves the tailnet host without the CLI", () => + Effect.gen(function* () { + const lookupService = useMacTailnet().mockResolvedValueOnce({ + 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" }, + ]); + // 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 fff8a8168437b..b620c1352656f 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -1,16 +1,52 @@ 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 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"; +import * as NodeDns from "node:dns"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; 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. +// 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( + (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 : address; + }, + () => address, + ), + ).pipe( + Effect.timeoutOption(TAILNET_NAME_LOOKUP_TIMEOUT), + Effect.map(Option.getOrElse(() => address)), + ); +}); export class RemoteOpenTargets extends Context.Service< RemoteOpenTargets, @@ -23,6 +59,8 @@ export class RemoteOpenTargets extends Context.Service< export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const net = yield* NetService.NetService; + 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. @@ -33,18 +71,29 @@ export const make = Effect.gen(function* () { ); const username = yield* HostProcess.Username; - const magicDnsName = yield* readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), - Effect.orElseSucceed(() => null), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); + // 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), + 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; the macOS apps cannot run an SSH server. if ( - magicDnsName !== null && + tailnetHost !== null && + !avoidTailscaleCli && (yield* readTailscaleSshEnabled.pipe( Effect.orElseSucceed(() => false), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -52,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 }), }); }