From dc70b58a200b6247ef4b74d5066793ec067146a2 Mon Sep 17 00:00:00 2001 From: Long H Nguyen <258177349+chrislongnguyen@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:41:28 +0700 Subject: [PATCH 1/7] test(peer): reproduce missing direct MagicDNS advertisements --- .../backend/tailscaleEndpointProvider.test.ts | 29 +++++++++++++++ .../src/environment/DirectEndpoints.test.ts | 37 ++++++++++++++++++- apps/server/src/peer/PeerLinks.test.ts | 35 ++++++++++++++++++ .../client-runtime/src/linkRequest.test.ts | 6 +-- 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 054655917cecc..a18a6c7a845af 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -80,3 +80,32 @@ describe("tailscale endpoint provider", () => { }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), ); }); + +it.effect("advertises direct MagicDNS only for a running Self on the listener interface", () => + Effect.gen(function* () { + for (const [bindHost, backendState, ip, expected] of [ + ["0.0.0.0", "Running", "100.74.126.34", true], + ["100.74.126.34", "Running", "100.74.126.34", true], + ["127.0.0.1", "Running", "100.74.126.34", false], + ["192.168.1.2", "Running", "100.74.126.34", false], + ["0.0.0.0", "Stopped", "100.74.126.34", false], + ["0.0.0.0", "Unknown", "100.74.126.34", false], + ["::1", "Running", "100.74.126.34", false], + ["0.0.0.0", "Running", "100.85.0.1", false], + ] as const) { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + bindHost, + networkInterfaces: { tailscale0: protonAndTailscaleInterfaces.tailscale0 }, + statusJson: JSON.stringify({ + BackendState: backendState, + Self: { DNSName: "desktop.tail.ts.net.", TailscaleIPs: [ip] }, + }), + }); + assert.strictEqual( + endpoints.some((e) => e.httpBaseUrl === "http://desktop.tail.ts.net:3773/"), + expected, + ); + } + }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), +); diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index def0434140c32..b566bc858468e 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; -import type * as NodeOS from "node:os"; +import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; @@ -11,12 +11,14 @@ import * as Stream from "effect/Stream"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import * as DirectEndpoints from "./DirectEndpoints.ts"; import { resolveBoundEndpoints } from "./DirectEndpoints.ts"; +vi.mock("node:os", { spy: true }); + const INTERFACES: ReturnType = { lo0: [ { @@ -226,6 +228,7 @@ describe("resolveBoundEndpoints", () => { }); const TAILSCALE_STATUS_JSON = JSON.stringify({ + BackendState: "Running", Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, }); @@ -356,3 +359,33 @@ describe("DirectEndpoints Tailscale Serve", () => { }), ); }); + +effectIt.effect("advertises direct MagicDNS on a bound current Self address without Serve", () => + Effect.gen(function* () { + using _interfaces = vi + .spyOn(NodeOS, "networkInterfaces") + .mockReturnValue({ tailscale0: vpnInterface("100.64.1.2") }); + const service = yield* DirectEndpoints.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ + ...config, + host: "0.0.0.0", + tailscaleServeEnabled: false, + })), + ).pipe(Layer.provide(layerServeConfig("0.0.0.0"))), + layerTailscaleUp, + layerServeProbe(502), + ), + ), + ); + expect(yield* service.resolve()).toContainEqual({ + kind: "tailnet", + httpBaseUrl: "http://bb-1.tail1234.ts.net:3773/", + }); + _interfaces.mockReturnValue({}); + expect(yield* service.resolveAvailable()).toEqual([]); + }), +); diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 3c510c35e6904..88e3105f6e2d5 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -1,3 +1,4 @@ +import { vi } from "vite-plus/test"; import { NodeHttpServer } from "@effect/platform-node"; import { type ExecutionEnvironmentDescriptor, OrchestratorMcpFailure } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; @@ -580,3 +581,37 @@ it.effect("linking a picked machine that answers nowhere names it and links noth }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + +it.effect("links through an advertised HTTP MagicDNS candidate while refusing numeric CGNAT", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const originalFetch = globalThis.fetch; + using _fetch = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (url.hostname === "box.tail.ts.net") { + const target = new URL(b.url); + url.host = target.host; + return originalFetch(new Request(url, request)); + } + return originalFetch(input, init); + }); + const a = yield* makeA(laptop); + const pairing = yield* b.auth.issuePairingCredential(); + const origin = "http://box.tail.ts.net:3773"; + const linked = yield* a.links.link({ + url: "http://100.64.1.2:3773", + alternateUrls: [origin], + expectedEnvironmentId: box.environmentId, + pairingCode: pairing.credential, + access: "auto", + }); + expect(linked).toMatchObject({ environmentId: box.environmentId, urls: [origin] }); + const refused = yield* a.links + .link({ url: "http://100.64.1.2:3773", pairingCode: pairing.credential, access: "auto" }) + .pipe(Effect.flip); + expect(refused.message).toContain("plain http"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/packages/client-runtime/src/linkRequest.test.ts b/packages/client-runtime/src/linkRequest.test.ts index 25647abe5aa27..c4e48ddd63848 100644 --- a/packages/client-runtime/src/linkRequest.test.ts +++ b/packages/client-runtime/src/linkRequest.test.ts @@ -53,7 +53,7 @@ describe("linkRequestAnswerInput", () => { const vps = { environmentId: EnvironmentId.make("environment-vps"), label: "VPS", - urls: ["https://vps.ts.net", "http://10.0.0.2:3773"], + urls: ["https://vps.ts.net", "http://vps.ts.net:3773/", "http://10.0.0.2:3773"], }; it("answers with the machine picked and every address known for it", () => { @@ -189,7 +189,7 @@ describe("linkableMachines", () => { const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); const vps = presentation("environment-vps", "VPS", { saved: ["https://vps.example.com", "https://vps.ts.net"], - reported: ["https://vps.ts.net"], + reported: ["https://vps.ts.net", "http://vps.ts.net:3773/"], }); const mini = presentation("environment-mini", "Mini", { connected: false }); expect( @@ -207,7 +207,7 @@ describe("linkableMachines", () => { connected: true, connectOnly: false, linked: false, - urls: ["https://vps.ts.net", "https://vps.example.com"], + urls: ["https://vps.ts.net", "http://vps.ts.net:3773/", "https://vps.example.com"], }, { environmentId: mini.environmentId, From 9783be82d2055731ca7d72b194534c0f425db579 Mon Sep 17 00:00:00 2001 From: Long H Nguyen <258177349+chrislongnguyen@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:55:00 +0700 Subject: [PATCH 2/7] fix(peer): advertise bound Tailnet listeners through MagicDNS --- .../src/backend/DesktopServerExposure.ts | 1 + .../backend/tailscaleEndpointProvider.test.ts | 68 +++++++++++++------ .../src/backend/tailscaleEndpointProvider.ts | 34 +++++++++- .../server/src/environment/DirectEndpoints.ts | 65 ++++++++++++------ apps/server/src/peer/PeerLinks.test.ts | 17 ++--- apps/server/src/peer/PeerLinks.testkit.ts | 9 ++- packages/tailscale/src/tailscale.test.ts | 1 + packages/tailscale/src/tailscale.ts | 21 ++++++ 8 files changed, 163 insertions(+), 53 deletions(-) diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 76c2392c0bd66..b48baf5925199 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -617,6 +617,7 @@ export const make = Effect.gen(function* () { const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: state.port, + bindHost: state.bindHost, serveEnabled: state.tailscaleServeEnabled, servePort: state.tailscaleServePort, networkInterfaces: currentNetworkInterfaces, diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index a18a6c7a845af..79b66fb300958 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -83,29 +83,57 @@ describe("tailscale endpoint provider", () => { it.effect("advertises direct MagicDNS only for a running Self on the listener interface", () => Effect.gen(function* () { - for (const [bindHost, backendState, ip, expected] of [ - ["0.0.0.0", "Running", "100.74.126.34", true], - ["100.74.126.34", "Running", "100.74.126.34", true], - ["127.0.0.1", "Running", "100.74.126.34", false], - ["192.168.1.2", "Running", "100.74.126.34", false], - ["0.0.0.0", "Stopped", "100.74.126.34", false], - ["0.0.0.0", "Unknown", "100.74.126.34", false], - ["::1", "Running", "100.74.126.34", false], - ["0.0.0.0", "Running", "100.85.0.1", false], - ] as const) { + for (const serveEnabled of [false, true]) { + for (const [bindHost, backendState, ip, expected] of [ + ["0.0.0.0", "Running", "100.74.126.34", true], + ["100.74.126.34", "Running", "100.74.126.34", true], + ["127.0.0.1", "Running", "100.74.126.34", false], + ["192.168.1.2", "Running", "100.74.126.34", false], + ["0.0.0.0", "Stopped", "100.74.126.34", false], + ["0.0.0.0", "Unknown", "100.74.126.34", false], + ["::1", "Running", "100.74.126.34", false], + ["0.0.0.0", "Running", "100.85.0.1", false], + ] as const) { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + bindHost, + serveEnabled, + probe: () => Effect.succeed(true), + networkInterfaces: { tailscale0: protonAndTailscaleInterfaces.tailscale0 }, + statusJson: JSON.stringify({ + BackendState: backendState, + Self: { DNSName: "desktop.tail.ts.net.", TailscaleIPs: [ip] }, + }), + }); + assert.strictEqual( + endpoints.some((e) => e.httpBaseUrl === "http://desktop.tail.ts.net:3773/"), + expected, + ); + if (expected && serveEnabled) { + const urls = endpoints.map((endpoint) => endpoint.httpBaseUrl); + assert.isBelow( + urls.indexOf("https://desktop.tail.ts.net/"), + urls.indexOf("http://desktop.tail.ts.net:3773/"), + ); + } + } + } + for (const statusJson of [ + null, + "not-json", + "{}", + JSON.stringify({ + BackendState: "Running", + Self: { DNSName: "evil.example/path", TailscaleIPs: ["100.74.126.34"] }, + }), + ]) { const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: 3773, - bindHost, - networkInterfaces: { tailscale0: protonAndTailscaleInterfaces.tailscale0 }, - statusJson: JSON.stringify({ - BackendState: backendState, - Self: { DNSName: "desktop.tail.ts.net.", TailscaleIPs: [ip] }, - }), + bindHost: "0.0.0.0", + networkInterfaces: protonAndTailscaleInterfaces, + statusJson, }); - assert.strictEqual( - endpoints.some((e) => e.httpBaseUrl === "http://desktop.tail.ts.net:3773/"), - expected, - ); + assert.isFalse(endpoints.some((endpoint) => endpoint.label === "Tailscale MagicDNS")); } }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), ); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 4b75afccdb5bc..3d89b72ea1dca 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -2,6 +2,7 @@ import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint"; import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; import { buildTailscaleHttpsBaseUrl, + buildTailscaleDirectBaseUrl, isTailscaleIpv4Address, parseTailscaleStatus, probeTailscaleHttpsEndpoint, @@ -114,6 +115,7 @@ const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")( function* (input: { readonly port: number; + readonly bindHost?: string; readonly serveEnabled?: boolean; readonly servePort?: number; readonly networkInterfaces: NetworkInterfaces; @@ -145,9 +147,37 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd ...(input.probe === undefined ? {} : { probe: input.probe }), }); + const boundAddresses = Object.values(input.networkInterfaces).flatMap((entries) => + (entries ?? []) + .filter( + (entry) => + !entry.internal && + entry.family === "IPv4" && + (input.bindHost === "0.0.0.0" || + input.bindHost === "::" || + input.bindHost === entry.address), + ) + .map((entry) => entry.address), + ); + const directUrl = buildTailscaleDirectBaseUrl({ status, boundAddresses, port: input.port }); + const directEndpoints = + directUrl === null + ? [] + : [ + createAdvertisedEndpoint({ + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "desktop-addon", + id: `tailscale-magicdns-direct:${directUrl}`, + label: "Tailscale MagicDNS", + httpBaseUrl: directUrl, + reachability: "private-network", + status: "available", + description: "Reachable from devices on the same Tailnet.", + }), + ]; return Option.match(magicDnsEndpoint, { - onNone: () => ipEndpoints, - onSome: (endpoint) => [...ipEndpoints, endpoint], + onNone: () => [...ipEndpoints, ...directEndpoints], + onSome: (endpoint) => [...ipEndpoints, endpoint, ...directEndpoints], }); }, ); diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 47e9772e20fe1..c9fb147697c1b 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -1,6 +1,8 @@ import type { ServerDirectEndpoint } from "@t3tools/contracts"; import { buildTailscaleHttpsBaseUrl, + buildTailscaleDirectBaseUrl, + type TailscaleStatus, probeTailscaleHttpsEndpoint, readTailscaleStatus, } from "@t3tools/tailscale"; @@ -130,45 +132,64 @@ export const make = Effect.gen(function* () { : Effect.succeed(false); yield* Effect.forkDetach(onCloudVm); + const cachedStatus = yield* Ref.make(null); const servedEndpoint = yield* Ref.make>(Option.none()); const resolveAvailable = Effect.fn("DirectEndpoints.resolveAvailable")(function* () { + const interfaces = NodeOS.networkInterfaces(); const endpoints = [ ...resolveBoundEndpoints({ platform, host: config.host, port: config.port, - interfaces: NodeOS.networkInterfaces(), + interfaces, onCloudVm: yield* onCloudVm, }), ]; const servedUrl = yield* Ref.get(servedEndpoint); if (Option.isSome(servedUrl)) endpoints.push({ kind: "tailnet", httpBaseUrl: servedUrl.value }); + const directUrl = buildTailscaleDirectBaseUrl({ + status: yield* Ref.get(cachedStatus), + port: config.port, + boundAddresses: endpoints.flatMap((endpoint) => { + const address = new URL(endpoint.httpBaseUrl).hostname; + return Object.values(interfaces).some((entries) => + entries?.some((entry) => !entry.internal && entry.address === address), + ) + ? [address] + : []; + }), + }); + if (directUrl !== null) endpoints.push({ kind: "tailnet", httpBaseUrl: directUrl }); return endpoints; }); 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( - Effect.map((status) => status.magicDnsName), - Effect.flatMap((magicDnsName) => { - if (magicDnsName === null) return Effect.succeed(null); - const httpBaseUrl = buildTailscaleHttpsBaseUrl({ - magicDnsName, - servePort: config.tailscaleServePort, - }); - return probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe( - Effect.map((reachable) => (reachable ? httpBaseUrl : null)), - ); + if (isLoopbackHost(config.host) && !config.tailscaleServeEnabled) + return yield* resolveAvailable(); + const discovered = yield* readTailscaleStatus.pipe( + Effect.flatMap((status) => + Effect.gen(function* () { + let servedUrl: string | null = null; + if (config.tailscaleServeEnabled && status.magicDnsName !== null) { + const url = buildTailscaleHttpsBaseUrl({ + magicDnsName: status.magicDnsName, + servePort: config.tailscaleServePort, + }); + if (yield* probeTailscaleHttpsEndpoint({ baseUrl: url })) servedUrl = url; + } + return { status, servedUrl }; }), - 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); - } + ), + Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT), + Effect.orElseSucceed(() => Option.none()), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + yield* Ref.set(cachedStatus, Option.isSome(discovered) ? discovered.value.status : null); + yield* Ref.set( + servedEndpoint, + Option.flatMap(discovered, (value) => Option.fromNullishOr(value.servedUrl)), + ); return yield* resolveAvailable(); }); diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 88e3105f6e2d5..3d07088181c7d 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -1,4 +1,3 @@ -import { vi } from "vite-plus/test"; import { NodeHttpServer } from "@effect/platform-node"; import { type ExecutionEnvironmentDescriptor, OrchestratorMcpFailure } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; @@ -75,8 +74,8 @@ const probeHandlers = McpToolAccess.toLayer(ProbeToolkit, { ), }); -const serveB = (descriptor: ExecutionEnvironmentDescriptor) => - servePeer(descriptor, McpHttpServer.toolkitRegistration(ProbeToolkit, probeHandlers)); +const serveB = (descriptor: ExecutionEnvironmentDescriptor, origin?: string) => + servePeer(descriptor, McpHttpServer.toolkitRegistration(ProbeToolkit, probeHandlers), origin); /** Environment A, which links to B. */ const makeA = ( @@ -585,9 +584,9 @@ it.effect("linking a picked machine that answers nowhere names it and links noth it.effect("links through an advertised HTTP MagicDNS candidate while refusing numeric CGNAT", () => Effect.scoped( Effect.gen(function* () { - const b = yield* serveB(box); - const originalFetch = globalThis.fetch; - using _fetch = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + const b = yield* serveB(box, "http://box.tail.ts.net:3773"); + const originalFetch = yield* FetchHttpClient.Fetch; + const mappedFetch: typeof globalThis.fetch = (input, init) => { const request = new Request(input, init); const url = new URL(request.url); if (url.hostname === "box.tail.ts.net") { @@ -596,8 +595,10 @@ it.effect("links through an advertised HTTP MagicDNS candidate while refusing nu return originalFetch(new Request(url, request)); } return originalFetch(input, init); - }); - const a = yield* makeA(laptop); + }; + const a = yield* makeA(laptop).pipe( + Effect.provideService(FetchHttpClient.Fetch, mappedFetch), + ); const pairing = yield* b.auth.issuePairingCredential(); const origin = "http://box.tail.ts.net:3773"; const linked = yield* a.links.link({ diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts index c72c5ba8c81bc..056d679fd85db 100644 --- a/apps/server/src/peer/PeerLinks.testkit.ts +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -63,6 +63,7 @@ export const descriptorOf = ( export const servePeer = ( initial: ExecutionEnvironmentDescriptor, tools: Layer.Layer, + origin?: string, ) => Effect.gen(function* () { const descriptor = yield* Ref.make(initial); @@ -94,7 +95,13 @@ export const servePeer = ( if (authorization !== undefined) { yield* Ref.update(bearers, (seen) => [...seen, authorization]); } - return yield* httpEffect; + return yield* origin === undefined + ? httpEffect + : Effect.provideService( + httpEffect, + HttpServerRequest.HttpServerRequest, + request.modify({ headers: { ...request.headers, host: new URL(origin).host } }), + ); }), { global: true }, ); diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 96f6e7721ed66..ee7149e9f344c 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -194,6 +194,7 @@ describe("tailscale", () => { Effect.gen(function* () { const status = yield* parseTailscaleStatus(tailscaleStatusJson); assert.deepEqual(status, { + running: false, magicDnsName: "desktop.tail.ts.net", tailnetIpv4Addresses: ["100.100.100.100"], httpsServeEnabled: null, diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 4eb20f1150b6c..5b6237097a99a 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -144,12 +144,14 @@ const TailscaleStatusSelf = Schema.Struct({ }); const TailscaleStatusJson = Schema.Struct({ + BackendState: Schema.optional(Schema.Unknown), Self: Schema.optional(TailscaleStatusSelf), }); export type TailscaleStatusJson = typeof TailscaleStatusJson.Type; export interface TailscaleStatus { + readonly running: boolean; readonly magicDnsName: string | null; readonly tailnetIpv4Addresses: readonly string[]; /** @@ -219,6 +221,7 @@ export const parseTailscaleStatus = ( const capMap = parsed.Self?.CapMap; return { + running: parsed.BackendState === "Running", magicDnsName: normalizeMagicDnsName(parsed), tailnetIpv4Addresses, httpsServeEnabled: @@ -370,6 +373,24 @@ export const readTailscaleSshEnabled = readTailscaleOutput("debug", ["debug", "p Effect.flatMap(parseTailscaleSshEnabled), ); +export function buildTailscaleDirectBaseUrl(input: { + readonly status: TailscaleStatus | null; + readonly boundAddresses: readonly string[]; + readonly port: number; +}): string | null { + const status = input.status; + if (!status?.running || !status.magicDnsName) return null; + if ( + !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.ts\.net$/iu.test( + status.magicDnsName, + ) + ) + return null; + if (!status.tailnetIpv4Addresses.some((address) => input.boundAddresses.includes(address))) + return null; + return `http://${status.magicDnsName}:${input.port}/`; +} + export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; readonly servePort?: number; From 3f3bcf8f29ec377379508eb348711eea06c58198 Mon Sep 17 00:00:00 2001 From: Long H Nguyen <258177349+chrislongnguyen@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:22:31 +0700 Subject: [PATCH 3/7] fix(peer): retain direct discovery across HTTPS timeouts and IPv6 binds --- .../backend/tailscaleEndpointProvider.test.ts | 18 ++- .../src/backend/tailscaleEndpointProvider.ts | 21 ++-- .../src/environment/DirectEndpoints.test.ts | 115 +++++++++++++++--- .../server/src/environment/DirectEndpoints.ts | 23 ++-- packages/tailscale/src/tailscale.test.ts | 1 + packages/tailscale/src/tailscale.ts | 44 ++++++- 6 files changed, 172 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 79b66fb300958..469e1b40bf058 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -69,6 +69,7 @@ describe("tailscale endpoint provider", () => { utun4: [ { address: "100.74.126.34", family: "IPv4", internal: false }, { address: "fd7a:115c:a1e0::1234", family: "IPv6", internal: false }, + { address: "fd7a:115c:a1e0::9999", family: "IPv6", internal: false }, ], }, statusJson: null, @@ -87,6 +88,15 @@ it.effect("advertises direct MagicDNS only for a running Self on the listener in for (const [bindHost, backendState, ip, expected] of [ ["0.0.0.0", "Running", "100.74.126.34", true], ["100.74.126.34", "Running", "100.74.126.34", true], + ["::ffff:100.74.126.34", "Running", "100.74.126.34", true], + ["::ffff:644a:7e22", "Running", "100.74.126.34", true], + ["fd7a:115c:a1e0::1234", "Running", "fd7a:115c:a1e0::1234", true], + ["::", "Running", "fd7a:115c:a1e0::1234", true], + ["::ffff:127.0.0.1", "Running", "100.74.126.34", false], + ["fd7a:115c:a1e0:0:0:0:0:1234", "Running", "fd7a:115c:a1e0::1234", true], + ["fd7a:115c:a1e0::9999", "Running", "fd7a:115c:a1e0::1234", false], + ["2001:db8::1", "Running", "fd7a:115c:a1e0::1234", false], + ["0.0.0.0", "Running", "fd7a:115c:a1e0::1234", false], ["127.0.0.1", "Running", "100.74.126.34", false], ["192.168.1.2", "Running", "100.74.126.34", false], ["0.0.0.0", "Stopped", "100.74.126.34", false], @@ -99,7 +109,13 @@ it.effect("advertises direct MagicDNS only for a running Self on the listener in bindHost, serveEnabled, probe: () => Effect.succeed(true), - networkInterfaces: { tailscale0: protonAndTailscaleInterfaces.tailscale0 }, + networkInterfaces: { + tailscale0: [ + ...protonAndTailscaleInterfaces.tailscale0, + { address: "fd7a:115c:a1e0::1234", family: "IPv6", internal: false }, + { address: "fd7a:115c:a1e0::9999", family: "IPv6", internal: false }, + ], + }, statusJson: JSON.stringify({ BackendState: backendState, Self: { DNSName: "desktop.tail.ts.net.", TailscaleIPs: [ip] }, diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 3d89b72ea1dca..85c27aca5a125 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -147,19 +147,14 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd ...(input.probe === undefined ? {} : { probe: input.probe }), }); - const boundAddresses = Object.values(input.networkInterfaces).flatMap((entries) => - (entries ?? []) - .filter( - (entry) => - !entry.internal && - entry.family === "IPv4" && - (input.bindHost === "0.0.0.0" || - input.bindHost === "::" || - input.bindHost === entry.address), - ) - .map((entry) => entry.address), - ); - const directUrl = buildTailscaleDirectBaseUrl({ status, boundAddresses, port: input.port }); + const directUrl = buildTailscaleDirectBaseUrl({ + status, + host: input.bindHost ?? "127.0.0.1", + localAddresses: Object.values(input.networkInterfaces).flatMap((entries) => + (entries ?? []).filter((entry) => !entry.internal).map((entry) => entry.address), + ), + port: input.port, + }); const directEndpoints = directUrl === null ? [] diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index b566bc858468e..c19938d47453e 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -3,6 +3,7 @@ import { it as effectIt } from "@effect/vitest"; import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; +import * as TestClock from "effect/testing/TestClock"; import * as Fiber from "effect/Fiber"; import * as Ref from "effect/Ref"; import * as Layer from "effect/Layer"; @@ -229,7 +230,7 @@ describe("resolveBoundEndpoints", () => { const TAILSCALE_STATUS_JSON = JSON.stringify({ BackendState: "Running", - Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, + Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2", "fd7a:115c:a1e0::1234"] }, }); const layerTailscaleUp = Layer.succeed( @@ -365,27 +366,105 @@ effectIt.effect("advertises direct MagicDNS on a bound current Self address with using _interfaces = vi .spyOn(NodeOS, "networkInterfaces") .mockReturnValue({ tailscale0: vpnInterface("100.64.1.2") }); + for (const [host, expected] of [ + ["0.0.0.0", true], + ["100.64.1.2", true], + ["fd7a:115c:a1e0::1234", true], + ["fd7a:115c:a1e0:0:0:0:0:1234", true], + ["::ffff:100.64.1.2", true], + ["::ffff:6440:102", true], + ["0:0:0:0:0:ffff:100.64.1.2", true], + ["fd7a:115c:a1e0::9999", false], + ["2001:db8::1", false], + ["::1", false], + ] as const) { + _interfaces.mockReturnValue({ + tailscale0: [ + ...vpnInterface("100.64.1.2"), + ...(INTERFACES.utun4 ?? []) + .filter((entry) => entry.family === "IPv6") + .flatMap((entry) => [ + { ...entry, address: "fd7a:115c:a1e0::1234" }, + { ...entry, address: "fd7a:115c:a1e0::9999" }, + ]), + ], + }); + const service = yield* DirectEndpoints.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ + ...config, + host, + tailscaleServeEnabled: false, + })), + ).pipe(Layer.provide(layerServeConfig("0.0.0.0"))), + layerTailscaleUp, + layerServeProbe(502), + ), + ), + ); + expect( + (yield* service.resolve()).some( + (endpoint) => endpoint.httpBaseUrl === "http://bb-1.tail1234.ts.net:3773/", + ), + ).toBe(expected); + _interfaces.mockReturnValue({}); + expect( + (yield* service.resolveAvailable()).some( + (endpoint) => endpoint.httpBaseUrl === "http://bb-1.tail1234.ts.net:3773/", + ), + ).toBe(false); + } + }), +); + +effectIt.effect("keeps completed Self discovery when HTTPS exceeds the shared deadline", () => + Effect.gen(function* () { + using _interfaces = vi + .spyOn(NodeOS, "networkInterfaces") + .mockReturnValue({ tailscale0: vpnInterface("100.64.1.2") }); + const statusStarted = yield* Deferred.make(); + const probeStarted = yield* Deferred.make(); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner.pipe( + Effect.provide(layerTailscaleUp), + ); const service = yield* DirectEndpoints.make.pipe( Effect.provide( Layer.mergeAll( - Layer.effect( - ServerConfig.ServerConfig, - Effect.map(ServerConfig.ServerConfig, (config) => ({ - ...config, - host: "0.0.0.0", - tailscaleServeEnabled: false, - })), - ).pipe(Layer.provide(layerServeConfig("0.0.0.0"))), - layerTailscaleUp, - layerServeProbe(502), + layerServeConfig("0.0.0.0"), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Deferred.succeed(statusStarted, undefined).pipe( + Effect.andThen(Effect.sleep("1 second")), + Effect.andThen(spawner.spawn(command)), + ), + ), + ), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => + Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)), + ), + ), ), ), ); - expect(yield* service.resolve()).toContainEqual({ - kind: "tailnet", - httpBaseUrl: "http://bb-1.tail1234.ts.net:3773/", - }); - _interfaces.mockReturnValue({}); - expect(yield* service.resolveAvailable()).toEqual([]); - }), + const resolving = yield* Effect.forkScoped(service.resolve()); + yield* Deferred.await(statusStarted); + const numeric = [{ kind: "tailnet", httpBaseUrl: "http://100.64.1.2:3773/" }]; + expect(yield* service.resolveAvailable()).toEqual(numeric); + yield* TestClock.adjust("1 second"); + yield* Deferred.await(probeStarted); + expect(yield* service.resolveAvailable()).toEqual(numeric); + yield* TestClock.adjust("2 seconds"); + const endpoints = yield* Fiber.join(resolving); + expect(endpoints).toEqual([ + ...numeric, + { kind: "tailnet", httpBaseUrl: "http://bb-1.tail1234.ts.net:3773/" }, + ]); + expect(yield* service.resolveAvailable()).toEqual(endpoints); + }).pipe(Effect.scoped), ); diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index c9fb147697c1b..34c7bb6d6377b 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -150,14 +150,10 @@ export const make = Effect.gen(function* () { const directUrl = buildTailscaleDirectBaseUrl({ status: yield* Ref.get(cachedStatus), port: config.port, - boundAddresses: endpoints.flatMap((endpoint) => { - const address = new URL(endpoint.httpBaseUrl).hostname; - return Object.values(interfaces).some((entries) => - entries?.some((entry) => !entry.internal && entry.address === address), - ) - ? [address] - : []; - }), + host: config.host, + localAddresses: Object.values(interfaces).flatMap((entries) => + (entries ?? []).filter((entry) => !entry.internal).map((entry) => entry.address), + ), }); if (directUrl !== null) endpoints.push({ kind: "tailnet", httpBaseUrl: directUrl }); return endpoints; @@ -166,7 +162,9 @@ export const make = Effect.gen(function* () { const resolve = Effect.gen(function* () { if (isLoopbackHost(config.host) && !config.tailscaleServeEnabled) return yield* resolveAvailable(); + const observedStatus = yield* Ref.make(null); const discovered = yield* readTailscaleStatus.pipe( + Effect.tap((status) => Ref.set(observedStatus, status)), Effect.flatMap((status) => Effect.gen(function* () { let servedUrl: string | null = null; @@ -177,7 +175,7 @@ export const make = Effect.gen(function* () { }); if (yield* probeTailscaleHttpsEndpoint({ baseUrl: url })) servedUrl = url; } - return { status, servedUrl }; + return servedUrl; }), ), Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT), @@ -185,11 +183,8 @@ export const make = Effect.gen(function* () { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), ); - yield* Ref.set(cachedStatus, Option.isSome(discovered) ? discovered.value.status : null); - yield* Ref.set( - servedEndpoint, - Option.flatMap(discovered, (value) => Option.fromNullishOr(value.servedUrl)), - ); + yield* Ref.set(cachedStatus, yield* Ref.get(observedStatus)); + yield* Ref.set(servedEndpoint, Option.flatMap(discovered, Option.fromNullishOr)); return yield* resolveAvailable(); }); diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index ee7149e9f344c..7c3fcd9c2e1d0 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -197,6 +197,7 @@ describe("tailscale", () => { running: false, magicDnsName: "desktop.tail.ts.net", tailnetIpv4Addresses: ["100.100.100.100"], + tailnetIpv6Addresses: ["fd7a:115c:a1e0::1"], httpsServeEnabled: null, }); }), diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 5b6237097a99a..8ef64fa9d6e5a 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,5 +1,6 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; -import { normalizeHostname } from "@t3tools/shared/hostClassification"; +import * as NodeNet from "node:net"; +import { isTailnetHost, normalizeHostname } from "@t3tools/shared/hostClassification"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -154,6 +155,7 @@ export interface TailscaleStatus { readonly running: boolean; readonly magicDnsName: string | null; readonly tailnetIpv4Addresses: readonly string[]; + readonly tailnetIpv6Addresses: readonly string[]; /** * `tailscale serve --https` needs the `https` capability, which the control * plane grants once the tailnet enables HTTPS certificates. `null` when the @@ -211,10 +213,17 @@ export const parseTailscaleStatus = ( Effect.map((parsed) => { const rawIps = parsed.Self?.TailscaleIPs; const tailnetIpv4Addresses: Array = []; + const tailnetIpv6Addresses: Array = []; if (Array.isArray(rawIps)) { for (const address of rawIps) { if (typeof address === "string" && isTailscaleIpv4Address(address)) { tailnetIpv4Addresses.push(address); + } else if ( + typeof address === "string" && + NodeNet.isIPv6(address) && + isTailnetHost(address) + ) { + tailnetIpv6Addresses.push(address); } } } @@ -224,6 +233,7 @@ export const parseTailscaleStatus = ( running: parsed.BackendState === "Running", magicDnsName: normalizeMagicDnsName(parsed), tailnetIpv4Addresses, + tailnetIpv6Addresses, httpsServeEnabled: typeof capMap === "object" && capMap !== null && !Array.isArray(capMap) ? Object.hasOwn(capMap, "https") @@ -373,9 +383,23 @@ export const readTailscaleSshEnabled = readTailscaleOutput("debug", ["debug", "p Effect.flatMap(parseTailscaleSshEnabled), ); +const normalizeIpAddress = (input: string): string | null => { + const address = normalizeHostname(input); + if (NodeNet.isIPv4(address)) return address; + if (!NodeNet.isIPv6(address)) return null; + if (!URL.canParse(`http://[${address}]`)) return null; + const normalized = new URL(`http://[${address}]`).hostname.slice(1, -1); + const mapped = /^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/u.exec(normalized); + if (mapped === null) return normalized; + const high = Number.parseInt(mapped[1]!, 16); + const low = Number.parseInt(mapped[2]!, 16); + return [high >>> 8, high & 0xff, low >>> 8, low & 0xff].join("."); +}; + export function buildTailscaleDirectBaseUrl(input: { readonly status: TailscaleStatus | null; - readonly boundAddresses: readonly string[]; + readonly host: string | undefined; + readonly localAddresses: readonly string[]; readonly port: number; }): string | null { const status = input.status; @@ -386,8 +410,20 @@ export function buildTailscaleDirectBaseUrl(input: { ) ) return null; - if (!status.tailnetIpv4Addresses.some((address) => input.boundAddresses.includes(address))) - return null; + const host = input.host === undefined ? "::" : normalizeIpAddress(input.host); + if (host === null) return null; + const selfAddresses = new Set( + [...status.tailnetIpv4Addresses, ...status.tailnetIpv6Addresses].map(normalizeIpAddress), + ); + const boundSelf = input.localAddresses.some((local) => { + const address = normalizeIpAddress(local); + return ( + address !== null && + selfAddresses.has(address) && + (host === "::" || (host === "0.0.0.0" && !address.includes(":")) || host === address) + ); + }); + if (!boundSelf) return null; return `http://${status.magicDnsName}:${input.port}/`; } From 2de065b2f2bcc76553fb049dbde61624639c08ac Mon Sep 17 00:00:00 2001 From: Long H Nguyen <258177349+chrislongnguyen@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:35:29 +0700 Subject: [PATCH 4/7] fix(tailscale): preserve loopback default in direct discovery --- .../src/environment/DirectEndpoints.test.ts | 52 +++++++++++++++++++ packages/tailscale/src/tailscale.ts | 2 +- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index c19938d47453e..5e2be16e86922 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -289,6 +289,58 @@ const resolveWithServe = (probeStatus: number) => ); describe("DirectEndpoints Tailscale Serve", () => { + for (const probeState of ["healthy", "failed", "timeout"] as const) { + effectIt.effect(`keeps an unspecified listener loopback when HTTPS is ${probeState}`, () => + Effect.gen(function* () { + using _interfaces = vi + .spyOn(NodeOS, "networkInterfaces") + .mockReturnValue({ tailscale0: vpnInterface("100.64.1.2") }); + const probeStarted = yield* Deferred.make(); + const service = yield* DirectEndpoints.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ ...config, host: undefined })), + ).pipe(Layer.provide(layerServeConfig())), + layerTailscaleUp, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Deferred.succeed(probeStarted, undefined).pipe( + Effect.andThen( + probeState === "timeout" + ? Effect.never + : Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { status: probeState === "healthy" ? 200 : 502 }), + ), + ), + ), + ), + ), + ), + ), + ), + ); + const resolving = yield* Effect.forkScoped(service.resolve()); + yield* Deferred.await(probeStarted); + if (probeState === "timeout") { + expect(yield* service.resolveAvailable()).toEqual([]); + yield* TestClock.adjust("3 seconds"); + } + const endpoints = yield* Fiber.join(resolving); + expect(endpoints).toEqual( + probeState === "healthy" + ? [{ kind: "tailnet", httpBaseUrl: "https://bb-1.tail1234.ts.net/" }] + : [], + ); + expect(yield* service.resolveAvailable()).toEqual(endpoints); + }).pipe(Effect.scoped), + ); + } + effectIt.effect("lists the tailnet name once Serve answers for this server", () => Effect.gen(function* () { expect(yield* resolveWithServe(200)).toEqual([ diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 8ef64fa9d6e5a..be238cafab127 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -410,7 +410,7 @@ export function buildTailscaleDirectBaseUrl(input: { ) ) return null; - const host = input.host === undefined ? "::" : normalizeIpAddress(input.host); + const host = normalizeIpAddress(input.host ?? "127.0.0.1"); if (host === null) return null; const selfAddresses = new Set( [...status.tailnetIpv4Addresses, ...status.tailnetIpv6Addresses].map(normalizeIpAddress), From 1e92a4f77f1618f2dc6ec99858ab7aeb0da142f5 Mon Sep 17 00:00:00 2001 From: Long H Nguyen <258177349+chrislongnguyen@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:13:23 +0700 Subject: [PATCH 5/7] ci: request one fresh build after browser click timeout From 8f60b76d7b5e8ac3eb31956c31ac54c10dcec9d5 Mon Sep 17 00:00:00 2001 From: Long H Nguyen <258177349+chrislongnguyen@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:22:59 +0700 Subject: [PATCH 6/7] test(tailscale): keep bind eligibility coverage in the shared helper --- .../backend/tailscaleEndpointProvider.test.ts | 77 ++++-------------- .../src/environment/DirectEndpoints.test.ts | 80 ++++++------------- packages/tailscale/src/tailscale.test.ts | 71 ++++++++++++++++ 3 files changed, 111 insertions(+), 117 deletions(-) diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 469e1b40bf058..f74f5165f7d76 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -82,74 +82,25 @@ describe("tailscale endpoint provider", () => { ); }); -it.effect("advertises direct MagicDNS only for a running Self on the listener interface", () => +it.effect("advertises a bound IPv6 MagicDNS listener after the preferred HTTPS route", () => Effect.gen(function* () { for (const serveEnabled of [false, true]) { - for (const [bindHost, backendState, ip, expected] of [ - ["0.0.0.0", "Running", "100.74.126.34", true], - ["100.74.126.34", "Running", "100.74.126.34", true], - ["::ffff:100.74.126.34", "Running", "100.74.126.34", true], - ["::ffff:644a:7e22", "Running", "100.74.126.34", true], - ["fd7a:115c:a1e0::1234", "Running", "fd7a:115c:a1e0::1234", true], - ["::", "Running", "fd7a:115c:a1e0::1234", true], - ["::ffff:127.0.0.1", "Running", "100.74.126.34", false], - ["fd7a:115c:a1e0:0:0:0:0:1234", "Running", "fd7a:115c:a1e0::1234", true], - ["fd7a:115c:a1e0::9999", "Running", "fd7a:115c:a1e0::1234", false], - ["2001:db8::1", "Running", "fd7a:115c:a1e0::1234", false], - ["0.0.0.0", "Running", "fd7a:115c:a1e0::1234", false], - ["127.0.0.1", "Running", "100.74.126.34", false], - ["192.168.1.2", "Running", "100.74.126.34", false], - ["0.0.0.0", "Stopped", "100.74.126.34", false], - ["0.0.0.0", "Unknown", "100.74.126.34", false], - ["::1", "Running", "100.74.126.34", false], - ["0.0.0.0", "Running", "100.85.0.1", false], - ] as const) { - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - bindHost, - serveEnabled, - probe: () => Effect.succeed(true), - networkInterfaces: { - tailscale0: [ - ...protonAndTailscaleInterfaces.tailscale0, - { address: "fd7a:115c:a1e0::1234", family: "IPv6", internal: false }, - { address: "fd7a:115c:a1e0::9999", family: "IPv6", internal: false }, - ], - }, - statusJson: JSON.stringify({ - BackendState: backendState, - Self: { DNSName: "desktop.tail.ts.net.", TailscaleIPs: [ip] }, - }), - }); - assert.strictEqual( - endpoints.some((e) => e.httpBaseUrl === "http://desktop.tail.ts.net:3773/"), - expected, - ); - if (expected && serveEnabled) { - const urls = endpoints.map((endpoint) => endpoint.httpBaseUrl); - assert.isBelow( - urls.indexOf("https://desktop.tail.ts.net/"), - urls.indexOf("http://desktop.tail.ts.net:3773/"), - ); - } - } - } - for (const statusJson of [ - null, - "not-json", - "{}", - JSON.stringify({ - BackendState: "Running", - Self: { DNSName: "evil.example/path", TailscaleIPs: ["100.74.126.34"] }, - }), - ]) { const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: 3773, - bindHost: "0.0.0.0", - networkInterfaces: protonAndTailscaleInterfaces, - statusJson, + bindHost: "fd7a:115c:a1e0::1234", + serveEnabled, + probe: () => Effect.succeed(true), + networkInterfaces: { + tailscale0: [{ address: "fd7a:115c:a1e0::1234", family: "IPv6", internal: false }], + }, + statusJson: JSON.stringify({ + BackendState: "Running", + Self: { DNSName: "desktop.tail.ts.net.", TailscaleIPs: ["fd7a:115c:a1e0::1234"] }, + }), }); - assert.isFalse(endpoints.some((endpoint) => endpoint.label === "Tailscale MagicDNS")); + const urls = endpoints.map((endpoint) => endpoint.httpBaseUrl); + assert.deepEqual(urls, ["https://desktop.tail.ts.net/", "http://desktop.tail.ts.net:3773/"]); + assert.strictEqual(endpoints[0]?.status, serveEnabled ? "available" : "unavailable"); } }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), ); diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 5e2be16e86922..ece3a69bf5302 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -413,62 +413,34 @@ describe("DirectEndpoints Tailscale Serve", () => { ); }); -effectIt.effect("advertises direct MagicDNS on a bound current Self address without Serve", () => +effectIt.effect("advertises a bound IPv6 MagicDNS listener and drops a removed interface", () => Effect.gen(function* () { - using _interfaces = vi - .spyOn(NodeOS, "networkInterfaces") - .mockReturnValue({ tailscale0: vpnInterface("100.64.1.2") }); - for (const [host, expected] of [ - ["0.0.0.0", true], - ["100.64.1.2", true], - ["fd7a:115c:a1e0::1234", true], - ["fd7a:115c:a1e0:0:0:0:0:1234", true], - ["::ffff:100.64.1.2", true], - ["::ffff:6440:102", true], - ["0:0:0:0:0:ffff:100.64.1.2", true], - ["fd7a:115c:a1e0::9999", false], - ["2001:db8::1", false], - ["::1", false], - ] as const) { - _interfaces.mockReturnValue({ - tailscale0: [ - ...vpnInterface("100.64.1.2"), - ...(INTERFACES.utun4 ?? []) - .filter((entry) => entry.family === "IPv6") - .flatMap((entry) => [ - { ...entry, address: "fd7a:115c:a1e0::1234" }, - { ...entry, address: "fd7a:115c:a1e0::9999" }, - ]), - ], - }); - const service = yield* DirectEndpoints.make.pipe( - Effect.provide( - Layer.mergeAll( - Layer.effect( - ServerConfig.ServerConfig, - Effect.map(ServerConfig.ServerConfig, (config) => ({ - ...config, - host, - tailscaleServeEnabled: false, - })), - ).pipe(Layer.provide(layerServeConfig("0.0.0.0"))), - layerTailscaleUp, - layerServeProbe(502), - ), - ), - ); - expect( - (yield* service.resolve()).some( - (endpoint) => endpoint.httpBaseUrl === "http://bb-1.tail1234.ts.net:3773/", - ), - ).toBe(expected); - _interfaces.mockReturnValue({}); - expect( - (yield* service.resolveAvailable()).some( - (endpoint) => endpoint.httpBaseUrl === "http://bb-1.tail1234.ts.net:3773/", + using _interfaces = vi.spyOn(NodeOS, "networkInterfaces").mockReturnValue({ + tailscale0: (INTERFACES.utun4 ?? []).flatMap((entry) => + entry.family === "IPv6" ? [{ ...entry, address: "fd7a:115c:a1e0::1234" }] : [], + ), + }); + const service = yield* DirectEndpoints.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ + ...config, + host: "fd7a:115c:a1e0::1234", + tailscaleServeEnabled: false, + })), + ).pipe(Layer.provide(layerServeConfig("0.0.0.0"))), + layerTailscaleUp, + layerServeProbe(502), ), - ).toBe(false); - } + ), + ); + expect(yield* service.resolve()).toEqual([ + { kind: "tailnet", httpBaseUrl: "http://bb-1.tail1234.ts.net:3773/" }, + ]); + _interfaces.mockReturnValue({}); + expect(yield* service.resolveAvailable()).toEqual([]); }), ); diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 7c3fcd9c2e1d0..9abefb3c32565 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -8,6 +8,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { + buildTailscaleDirectBaseUrl, ensureTailscaleServe, parseTailscaleExitNode, parseTailscaleServeRoutesTo, @@ -200,9 +201,79 @@ describe("tailscale", () => { tailnetIpv6Addresses: ["fd7a:115c:a1e0::1"], httpsServeEnabled: null, }); + for (const [backendState, running] of [ + ["Running", true], + ["Stopped", false], + ["Unknown", false], + ] as const) { + const parsed = yield* parseTailscaleStatus(JSON.stringify({ BackendState: backendState })); + assert.strictEqual(parsed.running, running); + } }), ); + it("builds direct MagicDNS only for a running Self on the bound local interface", () => { + const localAddresses = ["100.74.126.34", "fd7a:115c:a1e0::1234", "fd7a:115c:a1e0::9999"]; + for (const [host, running, selfIp, expected] of [ + ["0.0.0.0", true, "100.74.126.34", true], + ["100.74.126.34", true, "100.74.126.34", true], + ["::ffff:100.74.126.34", true, "100.74.126.34", true], + ["::ffff:644a:7e22", true, "100.74.126.34", true], + ["0:0:0:0:0:ffff:100.74.126.34", true, "100.74.126.34", true], + ["fd7a:115c:a1e0::1234", true, "fd7a:115c:a1e0::1234", true], + ["fd7a:115c:a1e0:0:0:0:0:1234", true, "fd7a:115c:a1e0::1234", true], + ["fd7a:115c:a1e0::1234", true, "fd7a:115c:a1e0:0:0:0:0:1234", true], + ["::", true, "fd7a:115c:a1e0::1234", true], + ["::ffff:127.0.0.1", true, "100.74.126.34", false], + ["fd7a:115c:a1e0::9999", true, "fd7a:115c:a1e0::1234", false], + ["2001:db8::1", true, "fd7a:115c:a1e0::1234", false], + ["0.0.0.0", true, "fd7a:115c:a1e0::1234", false], + ["127.0.0.1", true, "100.74.126.34", false], + ["192.168.1.2", true, "100.74.126.34", false], + ["0.0.0.0", false, "100.74.126.34", false], + ["::1", true, "100.74.126.34", false], + ["0.0.0.0", true, "100.85.0.1", false], + ] as const) { + const url = buildTailscaleDirectBaseUrl({ + host, + port: 3773, + localAddresses, + status: { + running, + magicDnsName: "desktop.tail.ts.net", + tailnetIpv4Addresses: selfIp.includes(":") ? [] : [selfIp], + tailnetIpv6Addresses: selfIp.includes(":") ? [selfIp] : [], + httpsServeEnabled: null, + }, + }); + assert.strictEqual(url, expected ? "http://desktop.tail.ts.net:3773/" : null); + } + const status = { + running: true, + magicDnsName: "desktop.tail.ts.net", + tailnetIpv4Addresses: ["100.74.126.34"], + tailnetIpv6Addresses: [], + httpsServeEnabled: null, + }; + for (const unavailable of [ + null, + { ...status, magicDnsName: null }, + { ...status, magicDnsName: "evil.example/path" }, + ]) { + assert.isNull( + buildTailscaleDirectBaseUrl({ + status: unavailable, + host: "0.0.0.0", + localAddresses, + port: 3773, + }), + ); + } + assert.isNull( + buildTailscaleDirectBaseUrl({ status, host: "0.0.0.0", localAddresses: [], port: 3773 }), + ); + }); + it.effect("names the exit node in use", () => Effect.gen(function* () { const inUse = yield* parseTailscaleExitNode( From bedd7fb6e90be74fbee37d54c5ada5d9244f2e37 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 22:35:21 +0000 Subject: [PATCH 7/7] chore(server): note why direct discovery keeps status outside the timeout Co-Authored-By: Claude Opus 5.5 --- apps/server/src/environment/DirectEndpoints.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index e9d1ab45b8a8f..be8760bc4f5c7 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -162,6 +162,7 @@ export const make = Effect.gen(function* () { const resolve = Effect.gen(function* () { if (isLoopbackHost(config.host) && !config.tailscaleServeEnabled) return yield* resolveAvailable(); + // Kept outside the timeout so a slow HTTPS probe does not drop the direct route. const observedStatus = yield* Ref.make(null); const discovered = yield* readTailscaleStatus.pipe( Effect.tap((status) => Ref.set(observedStatus, status)),