Repository navigation
fix(server): stop re-spawning tailscale on every client connect on macOS #26103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
651eda6
50af6e6
ebba008
a568caa
c18aa12
4eb7e93
7e7bc93
c6ada0f
673d866
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Comment on lines
+30
to
+35
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Identify the Tailscale interface before selecting its IPv4 address.
If another VPN interface appears first, Preserve interface grouping and verify ownership before selecting an 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,36 +71,47 @@ 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<RemoteOpenTarget> = []; | ||
|
|
||
| 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), | ||
| )) | ||
| ) { | ||
| 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 }), | ||
| }); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High
environment/RemoteOpenTargets.ts:34lookupTailnetHostcan return another VPN's address or reverse hostname as the Tailscale host, causing remote editor links to target the wrong machine.isTailscaleIpv4Addressonly checks the100.64.0.0/10range, so an earlier adapter such as Proton at100.85.0.1is selected even when the actual Tailscale interface is present; verify interface identity or status membership before choosing an address.🚀 Reply "fix it for me" or copy this AI Prompt for your agent: