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 397a757704835..6184483b21d15 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -117,3 +117,26 @@ describe("tailscale endpoint provider", () => { }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), ); }); + +it.effect("advertises a bound IPv6 MagicDNS listener after the preferred HTTPS route", () => + Effect.gen(function* () { + for (const serveEnabled of [false, true]) { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + 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"] }, + }), + }); + 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/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 1b8d9dff4ed4d..f18edf5998684 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -6,6 +6,7 @@ import { } from "@t3tools/contracts"; import { buildTailscaleHttpsBaseUrl, + buildTailscaleDirectBaseUrl, isTailscaleIpv4Address, parseTailscaleStatus, probeTailscaleHttpsEndpoint, @@ -125,6 +126,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; @@ -157,9 +159,32 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd ...(input.probe === undefined ? {} : { probe: input.probe }), }); + 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 + ? [] + : [ + 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.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 6548b2ccd8c0e..c5d534a185b66 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -1,9 +1,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentId } from "@t3tools/contracts"; 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 TestClock from "effect/testing/TestClock"; import * as Fiber from "effect/Fiber"; import * as Ref from "effect/Ref"; import * as Layer from "effect/Layer"; @@ -13,13 +14,15 @@ import * as HttpClient from "effect/http/HttpClient"; import type * as HttpClientRequest from "effect/http/HttpClientRequest"; 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 * as ServerEnvironment from "./ServerEnvironment.ts"; import { resolveBoundEndpoints } from "./DirectEndpoints.ts"; +vi.mock("node:os", { spy: true }); + const INTERFACES: ReturnType = { lo0: [ { @@ -229,7 +232,8 @@ describe("resolveBoundEndpoints", () => { }); const TAILSCALE_STATUS_JSON = JSON.stringify({ - Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, + BackendState: "Running", + Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2", "fd7a:115c:a1e0::1234"] }, }); const layerTailscaleUp = Layer.succeed( @@ -273,14 +277,14 @@ const layerServeProbe = (status: number) => HttpClient.make((request) => Effect.succeed(serveProbeResponse(request, status))), ); -const layerServeConfig = (host = "127.0.0.1") => +const layerServeConfig = (host = "127.0.0.1", tailscaleServeEnabled = true) => Layer.effect( ServerConfig.ServerConfig, Effect.map(ServerConfig.ServerConfig, (config) => ({ ...config, host, port: 3773, - tailscaleServeEnabled: true, + tailscaleServeEnabled, tailscaleServePort: 443, })), ).pipe( @@ -375,3 +379,76 @@ describe("DirectEndpoints Tailscale 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: (INTERFACES.utun4 ?? []).flatMap((entry) => + entry.family === "IPv6" ? [{ ...entry, address: "fd7a:115c:a1e0::1234" }] : [], + ), + }); + const service = yield* DirectEndpoints.make.pipe( + Effect.provide( + Layer.mergeAll( + layerServeConfig("fd7a:115c:a1e0::1234", false), + layerTailscaleUp, + layerServeProbe(502), + ), + ), + ); + expect(yield* service.resolve()).toEqual([ + { kind: "tailnet", httpBaseUrl: "http://bb-1.tail1234.ts.net:3773/" }, + ]); + _interfaces.mockReturnValue({}); + expect(yield* service.resolveAvailable()).toEqual([]); + }), +); + +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( + 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)), + ), + ), + ), + ), + ); + 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); + }), +); diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 1f7d9f705620c..be8760bc4f5c7 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,62 @@ 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, + 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; }); 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, environmentId }).pipe( - Effect.map((reachable) => (reachable ? httpBaseUrl : null)), - ); + 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)), + Effect.flatMap((status) => + Effect.gen(function* () { + // Serve setup can fail without stopping startup; advertise only after it answers as this server. + 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, environmentId })) + servedUrl = url; + } + return 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, 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 095323e037ace..5fdc760ff8d04 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -13,6 +13,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { + buildTailscaleDirectBaseUrl, ensureTailscaleServe, parseTailscaleExitNode, parseTailscaleServeRootHolders, @@ -287,11 +288,75 @@ describe("tailscale", () => { running: null, magicDnsName: "desktop.tail.ts.net", tailnetIpv4Addresses: ["100.100.100.100"], + tailnetIpv6Addresses: ["fd7a:115c:a1e0::1"], httpsServeEnabled: null, }); }), ); + 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], + [undefined, true, "100.74.126.34", 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( diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 0456f95ab4850..b7e0eee064d66 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,7 +1,12 @@ import { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; -import { isLocalLoopbackHost, normalizeHostname } from "@t3tools/shared/hostClassification"; +import * as NodeNet from "node:net"; +import { + isLocalLoopbackHost, + isTailnetHost, + normalizeHostname, +} from "@t3tools/shared/hostClassification"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -163,6 +168,7 @@ export interface TailscaleStatus { readonly running: boolean | null; 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 @@ -220,10 +226,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); } } } @@ -233,6 +246,7 @@ export const parseTailscaleStatus = ( running: parsed.BackendState === undefined ? null : parsed.BackendState === "Running", magicDnsName: normalizeMagicDnsName(parsed), tailnetIpv4Addresses, + tailnetIpv6Addresses, httpsServeEnabled: typeof capMap === "object" && capMap !== null && !Array.isArray(capMap) ? Object.hasOwn(capMap, "https") @@ -382,6 +396,50 @@ 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 host: string | undefined; + readonly localAddresses: 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; + const host = normalizeIpAddress(input.host ?? "127.0.0.1"); + 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}/`; +} + export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; readonly servePort?: number;