From ec80933ac8cd02fec5c97b342462ccc9567cdb1e Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:26:05 +0530 Subject: [PATCH] fix(connect): Cloudflare and other VPN addresses no longer show as Tailscale (#17158) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 (1M context) --- .../settings/EnvironmentRoutesSection.tsx | 1 + .../src/environment/DirectEndpoints.test.ts | 37 +++++++++ .../server/src/environment/DirectEndpoints.ts | 34 ++++++-- docs/user/remote-access.md | 13 +-- .../client-runtime/src/connection/catalog.ts | 5 ++ .../src/connection/registry.test.ts | 52 +++++++++++- .../client-runtime/src/connection/registry.ts | 25 ++++-- .../src/connection/routes.test.ts | 50 ++++++++++- .../client-runtime/src/connection/routes.ts | 82 ++++++++++++++++--- .../src/connection/supervisor.ts | 9 +- packages/contracts/src/server.ts | 4 + packages/shared/src/hostClassification.ts | 16 +++- 12 files changed, 286 insertions(+), 42 deletions(-) diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index 69cc5b35df456d0e5703118c6e10b85f68c8534d..b20699ecb26bec71797c7b9ca3660e81646357c0 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -37,6 +37,7 @@ const ROUTE_ICONS: Record = { loopback: "desktopcomputer", lan: "wifi", tailnet: "point.3.connected.trianglepath.dotted", + vpn: { ios: "lock.shield", android: "lock" }, public: "globe", ssh: "terminal", }; diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 7767158eb56dd6599f4b0a2439f106fb066832e3..a8f5157a65ed155fd44c9e44a5237739b8aa0293 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -54,6 +54,7 @@ const INTERFACES: ReturnType = { cidr: "203.0.113.20/24", }, ], + // Tailscale on macOS: an anonymous utun that also carries a Tailscale IPv6 address. utun4: [ { address: "100.101.102.103", @@ -63,9 +64,30 @@ const INTERFACES: ReturnType = { internal: false, cidr: "100.101.102.103/32", }, + { + address: "fd7a:115c:a1e0::1", + netmask: "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + family: "IPv6", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "fd7a:115c:a1e0::1/128", + scopeid: 0, + }, ], }; +/** A VPN interface with an address from the same 100.64.0.0/10 range. */ +const vpnInterface = (address: string) => [ + { + address, + netmask: "255.255.255.255", + family: "IPv4" as const, + mac: "00:00:00:00:00:00", + internal: false, + cidr: `${address}/32`, + }, +]; + const virtualInterface = (address: string) => [ { address, @@ -111,6 +133,21 @@ describe("resolveBoundEndpoints", () => { ]); }); + it("tags only addresses on Tailscale's interface as tailnet, not other VPNs in its range", () => { + const interfaces = { + en0: INTERFACES.en0, + tailscale0: vpnInterface("100.70.1.2"), + CloudflareWARP: vpnInterface("100.96.0.1"), + utun7: vpnInterface("100.85.0.1"), + }; + expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces })).toEqual([ + { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }, + { kind: "tailnet", httpBaseUrl: "http://100.70.1.2:3773/" }, + { kind: "lan", httpBaseUrl: "http://100.96.0.1:3773/" }, + { kind: "lan", httpBaseUrl: "http://100.85.0.1:3773/" }, + ]); + }); + it("lists only the bound address for a specific bind", () => { expect( resolveBoundEndpoints({ host: "100.101.102.103", port: 3773, interfaces: INTERFACES }), diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index d875e6d1a96fd1e57aefae01474bf8c98b8e3e83..61762fac6800d699ca5614d26926460f6ec836e3 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -10,7 +10,6 @@ import type { ServerDirectEndpoint } from "@t3tools/contracts"; import { buildTailscaleHttpsBaseUrl, - isTailscaleIpv4Address, probeTailscaleHttpsEndpoint, readTailscaleStatus, } from "@t3tools/tailscale"; @@ -24,7 +23,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; -import { isPrivateNetworkHost } from "@t3tools/shared/hostClassification"; +import { isPrivateNetworkHost, isTailnetHost } from "@t3tools/shared/hostClassification"; import * as ServerConfig from "../config.ts"; import { formatHostForUrl, isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; @@ -46,16 +45,17 @@ export class DirectEndpoints extends Context.Service< >()("t3/environment/DirectEndpoints") {} /** - * Only numeric private-network and tailnet IPv4 addresses are reported. These - * routes are plain HTTP and carry the client's credential, so a public address - * would send it across the internet unencrypted, and a name (`server.local`) - * can resolve to a different machine on each client's network. + * Only numeric private-network IPv4 addresses, including VPN ranges such as + * 100.64.0.0/10, are reported. These routes are plain HTTP and carry the + * client's credential, so a public address would send it across the internet + * unencrypted, and a name (`server.local`) can resolve to a different machine + * on each client's network. */ const isAdvertisableAddress = (address: string): boolean => NodeNet.isIPv4(address) && !address.startsWith("127.") && !address.startsWith("169.254.") && - (isTailscaleIpv4Address(address) || isPrivateNetworkHost(address)); + isPrivateNetworkHost(address); /** * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and @@ -66,6 +66,19 @@ const isAdvertisableAddress = (address: string): boolean => const VIRTUAL_INTERFACE = /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge1\d\d)/; +/** + * Tailscale's own interface: `tailscale0` on Linux, "Tailscale" on Windows. + * macOS names every tunnel `utunN`, so there it is the one that also carries + * an address in Tailscale's IPv6 range. Cloudflare WARP and other VPNs assign + * from the same IPv4 range, so the IPv4 address alone proves nothing. + */ +const isTailscaleInterface = ( + name: string, + entries: ReadonlyArray, +): boolean => + /^tailscale/i.test(name) || + entries.some((entry) => entry.family === "IPv6" && isTailnetHost(entry.address)); + /** * Plain HTTP endpoints for the private addresses a server bound to `host` * accepts. IPv4 only: link-local and temporary IPv6 addresses change too @@ -77,6 +90,11 @@ export function resolveBoundEndpoints(input: { readonly interfaces: NetworkInterfacesMap; }): ReadonlyArray { if (isLoopbackHost(input.host)) return []; + const tailscaleAddresses = new Set( + Object.entries(input.interfaces).flatMap(([name, entries = []]) => + isTailscaleInterface(name, entries) ? entries.map((entry) => entry.address) : [], + ), + ); const addresses = isWildcardHost(input.host) ? Object.entries(input.interfaces) .flatMap(([name, entries]) => (VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? []))) @@ -87,7 +105,7 @@ export function resolveBoundEndpoints(input: { .map((entry) => entry.address) : [input.host!].filter(isAdvertisableAddress); return [...new Set(addresses)].map((address) => ({ - kind: isTailscaleIpv4Address(address) ? "tailnet" : "lan", + kind: tailscaleAddresses.has(address) ? "tailnet" : "lan", httpBaseUrl: `http://${formatHostForUrl(address)}:${input.port}/`, })); } diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 73062245c9bac11abf9a6029621bc4edd371033b..231823f13dcc9d954552e03ccd0a7ce1b194c832 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -64,11 +64,14 @@ another link to share. ### Reach one machine several ways -A machine can have more than one route: LAN, Tailscale, a public URL, SSH, or -T3 Connect. To add one, choose **Add route** in the machine's route list, or -next to it in the T3 Connect list. Pairing the same machine again over another -address also adds a route instead of a second machine. A new route is placed by -speed, in that order, and you can reorder routes at any time. +A machine can have more than one route: LAN, Tailscale, another VPN, a public +URL, SSH, or T3 Connect. Tailscale shares its `100.64.0.0/10` address range with +other VPNs such as Cloudflare WARP, so an address in that range shows as VPN +unless the machine confirms it is on Tailscale. To add a route, choose **Add +route** in the machine's route list, or next to it in the T3 Connect list. +Pairing the same machine again over another address also adds a route instead +of a second machine. A new route is placed by speed, in that order, and you can +reorder routes at any time. While connected through T3 Connect or a paired address, T3 Code also learns the machine's current LAN and Tailscale addresses and adds them as routes, so diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index 88896e78a8950249dae3698ba0de524dfca86748..b0b1526a450756ba6b80d8e5e324cfcd182e7717 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -33,6 +33,11 @@ export class BearerConnectionProfile extends Schema.TaggedClass Effect.Effect; readonly initialDisabled?: ReadonlyArray; + /** Direct addresses the connected server reports. */ + readonly directEndpoints?: ServerConfig["directEndpoints"]; }, ) { const storedTargets = yield* Ref.make>(initialTargets); @@ -409,7 +412,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( const session = yield* Effect.acquireRelease( Effect.succeed({ client: {} as RpcSession.RpcSession["client"], - initialConfig: Effect.die(new Error("Config is not used by registry tests.")), + initialConfig: + options?.directEndpoints === undefined + ? Effect.die(new Error("Config is not used by registry tests.")) + : Effect.succeed({ directEndpoints: options.directEndpoints } as ServerConfig), subscribeServerConfig: () => Stream.die(new Error("Config is not used by registry tests.")), ready: Effect.void, @@ -2067,6 +2073,48 @@ describe("EnvironmentRegistry routes", () => { }), ); + it.effect("saves the Tailscale mark when the server reports a learned address as tailnet", () => + Effect.gen(function* () { + const httpBaseUrl = "http://100.101.102.103:3773/"; + const learnedId = `learned:${LAN_TARGET.environmentId}:http://100.101.102.103:3773@${LAN_TARGET.connectionId}`; + const learned = new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId }); + // Learned by an earlier build, which saved no network. + const learnedProfile = new BearerConnectionProfile({ + connectionId: learnedId, + environmentId: LAN_TARGET.environmentId, + label: LAN_TARGET.label, + httpBaseUrl, + wsBaseUrl: "ws://100.101.102.103:3773/", + learned: true, + }); + const harness = yield* makeHarness( + [LAN_TARGET, learned], + [LAN_PROFILE, learnedProfile], + [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]], + { + directEndpoints: [ + { kind: "lan", httpBaseUrl: LAN_PROFILE.httpBaseUrl }, + { kind: "tailnet", httpBaseUrl }, + ], + }, + ); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* SubscriptionRef.changes(registry.entries).pipe( + Stream.map((entries) => entries.get(LAN_TARGET.environmentId)?.alternateRoutes?.[0]), + Stream.filter((route) => route !== undefined && connectionRouteKind(route) === "tailnet"), + Stream.runHead, + ); + expect((yield* Ref.get(harness.storedProfiles)).get(learnedId)).toMatchObject({ + network: "tailscale", + learned: true, + httpBaseUrl, + }); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("drops a learned route whose profile was not saved instead of duplicating it", () => Effect.gen(function* () { const learnedId = `learned:${LAN_TARGET.environmentId}:100.64.0.9:3773@${LAN_TARGET.connectionId}`; diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 1efc0548de89982d37b5ed4c83c9825686c1c63c..9b48e60463036694bee4d92b3dcaca7fc3f757a5 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -49,10 +49,10 @@ import { connectionRoutes, entryWithRoutes, findRouteToSameAddress, - isLearned, mergeLearnedRoutes, routesAfterRemoving, upsertRoute, + type ReportedEndpoint, } from "./routes.ts"; const isSshConnectionProfile = Schema.is(SshConnectionProfile); @@ -891,7 +891,7 @@ export const make = Effect.gen(function* () { const learnRoutes = Effect.fn("EnvironmentRegistry.learnRoutes")(function* (input: { readonly environmentId: EnvironmentId; readonly activeRoute: ConnectionRoute; - readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>; + readonly reported: ReadonlyArray; }) { return yield* withLeaseLock( input.environmentId, @@ -909,15 +909,24 @@ export const make = Effect.gen(function* () { }); if (routes === null) return Option.none(); const next = entryWithRoutes(entry, routes); - // A learned route owns its profile (address and authorization); the - // credential stays with the route it borrows from. - const previousIds = new Set( - connectionRoutes(entry).map((route) => connectionRouteId(route.target)), + // Save new learned profiles and any whose Tailscale mark changed. A + // learned route owns its profile; the credential stays with the route + // it borrows from. + const previousProfiles = new Map( + connectionRoutes(entry).map((route) => [ + connectionRouteId(route.target), + Option.getOrNull(route.profile), + ]), ); for (const route of routes) { - if (!isLearned(route) || previousIds.has(connectionRouteId(route.target))) continue; const profile = Option.getOrNull(route.profile); - if (profile !== null) yield* profiles.put(profile); + if ( + profile === null || + previousProfiles.get(connectionRouteId(route.target)) === profile + ) { + continue; + } + yield* profiles.put(profile); } yield* registrations.setRoutes(input.environmentId, persistedRoutes(next)); // Update the lease in place: the live session already works, and diff --git a/packages/client-runtime/src/connection/routes.test.ts b/packages/client-runtime/src/connection/routes.test.ts index 19b433f9573816bd0e7ebeba4ad817bc85eb23c7..3079294305c74b2c637ba704620070381d944a29 100644 --- a/packages/client-runtime/src/connection/routes.test.ts +++ b/packages/client-runtime/src/connection/routes.test.ts @@ -54,7 +54,9 @@ const PUBLIC = direct("public", "https://desk.example.com/"); describe("connection routes", () => { it("classifies direct routes by address", () => { expect(connectionRouteKind(LAN)).toBe("lan"); - expect(connectionRouteKind(direct("ip", "http://100.101.102.103:3773/"))).toBe("tailnet"); + // Tailscale, Cloudflare WARP and Mesh, and other VPNs share 100.64.0.0/10. + expect(connectionRouteKind(direct("ip", "http://100.101.102.103:3773/"))).toBe("vpn"); + expect(connectionRouteLabel(direct("mesh", "http://100.96.0.1:3773/"))).toBe("VPN"); expect(connectionRouteKind(TAILNET)).toBe("tailnet"); expect(connectionRouteKind(PUBLIC)).toBe("public"); expect(connectionRouteKind(direct("lo", "http://127.0.0.1:3773/"))).toBe("loopback"); @@ -156,6 +158,52 @@ describe("learned routes", () => { ]); }); + it("labels a learned address Tailscale only while the server reports it as tailnet", () => { + const tailscale = { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }; + const mesh = { kind: "lan", httpBaseUrl: "http://100.96.0.1:3773/" }; + const first = mergeLearnedRoutes({ + entry: relayOnly, + activeRoute: RELAY, + reported: [tailscale, mesh], + allowInsecure: true, + })!; + expect(first.map(connectionRouteLabel)).toEqual(["Tailscale", "VPN", "T3 Connect"]); + + // The server later finds the address is not on its Tailscale interface. + const corrected = mergeLearnedRoutes({ + entry: entryWithRoutes(relayOnly, first), + activeRoute: RELAY, + reported: [{ ...tailscale, kind: "lan" }, mesh], + allowInsecure: true, + })!; + expect(ids(corrected)).toEqual(ids(first)); + expect(corrected.map(connectionRouteLabel)).toEqual(["VPN", "VPN", "T3 Connect"]); + expect( + mergeLearnedRoutes({ + entry: entryWithRoutes(relayOnly, corrected), + activeRoute: RELAY, + reported: [{ ...tailscale, kind: "lan" }, mesh], + allowInsecure: true, + }), + ).toBeNull(); + }); + + it("labels a paired numeric Tailscale address Tailscale once the server confirms it", () => { + const paired = direct("paired", "http://100.101.102.103:3773/"); + const other = direct("other", "http://100.96.0.1:3773/"); + const entry = entryWithRoutes(relayOnly, [paired, other, RELAY]); + expect(connectionRouteLabel(paired)).toBe("VPN"); + const confirmed = mergeLearnedRoutes({ + entry, + activeRoute: paired, + reported: [{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }], + allowInsecure: true, + })!; + // Same routes in the same order; only the label of the confirmed one changes. + expect(ids(confirmed)).toEqual(["paired", "other", "relay"]); + expect(confirmed.map(connectionRouteLabel)).toEqual(["Tailscale", "VPN", "T3 Connect"]); + }); + it("leaves user routes alone and does not learn an address already saved", () => { const entry: ConnectionCatalogEntry = { target: LAN.target, diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts index 8b2ebf3e07435ae88068dc4b23e30f9fde768984..4691aa9389d8cb40c775a51de5e0c5821993ea6a 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -1,6 +1,7 @@ import { isLocalLoopbackHost, isPrivateNetworkHost, + isSharedAddressSpaceHost, isTailnetHost, } from "@t3tools/shared/hostClassification"; import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; @@ -15,12 +16,19 @@ import { BearerConnectionTarget, type ConnectionTarget } from "./model.ts"; /** * A saved environment can hold several routes: T3 Connect, direct URLs (LAN, - * tailnet, public), and SSH. The client connects over the first route in + * tailnet, other VPNs, public), and SSH. The client connects over the first route in * preference order that answers as the expected environment, and moves back * to a better one when it becomes reachable again. */ -export type ConnectionRouteKind = "relay" | "loopback" | "lan" | "tailnet" | "public" | "ssh"; +export type ConnectionRouteKind = + | "relay" + | "loopback" + | "lan" + | "tailnet" + | "vpn" + | "public" + | "ssh"; /** An environment has at most one T3 Connect route, so it needs no per-route id. */ export const RELAY_ROUTE_ID = "relay"; @@ -96,7 +104,11 @@ export function connectionRouteKind(route: ConnectionRoute): ConnectionRouteKind const hostname = routeHostname(route); if (hostname === null) return "public"; if (isLocalLoopbackHost(hostname)) return "loopback"; - if (isTailnetHost(hostname)) return "tailnet"; + const profile = Option.getOrNull(route.profile); + const tailscale = + profile?._tag === "BearerConnectionProfile" && profile.network === "tailscale"; + if (tailscale || isTailnetHost(hostname)) return "tailnet"; + if (isSharedAddressSpaceHost(hostname)) return "vpn"; return isPrivateNetworkHost(hostname) ? "lan" : "public"; } } @@ -106,6 +118,7 @@ const ROUTE_KIND_RANK: Record = { loopback: 0, lan: 1, tailnet: 2, + vpn: 2, public: 3, ssh: 4, relay: 5, @@ -166,7 +179,7 @@ function routeAddressKey(route: ConnectionRoute): string | null { } } -/** Short user-facing route description: "LAN", "Tailscale", "T3 Connect", a URL, or an SSH host. */ +/** Short user-facing route description: "LAN", "Tailscale", "VPN", "T3 Connect", a URL, or an SSH host. */ export function connectionRouteLabel(route: ConnectionRoute): string { switch (connectionRouteKind(route)) { case "relay": @@ -177,6 +190,8 @@ export function connectionRouteLabel(route: ConnectionRoute): string { return "LAN"; case "tailnet": return "Tailscale"; + case "vpn": + return "VPN"; case "ssh": { const profile = Option.getOrNull(route.profile); return profile?._tag === "SshConnectionProfile" @@ -212,13 +227,16 @@ export function hasRelayRoute( * in use: the paired token for a bearer route, the T3 Connect credential for * relay. A learned route the server still reports keeps its place, so the * user's order holds; one it no longer reports is dropped, so a changed LAN - * address replaces the old one. Routes the user saved are never touched, and - * an address already saved is not learned twice. + * address replaces the old one. Routes the user saved are never removed or + * moved, and an address already saved is not learned twice. Any direct route + * the server reports is marked as Tailscale while the server finds it on its + * Tailscale interface, so a route paired by its numeric Tailscale address keeps + * that label. */ export function mergeLearnedRoutes(input: { readonly entry: ConnectionCatalogEntry; readonly activeRoute: ConnectionRoute; - readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>; + readonly reported: ReadonlyArray; /** Plain HTTP routes are unusable from an HTTPS page (mixed content). */ readonly allowInsecure: boolean; }): ReadonlyArray | null { @@ -245,6 +263,7 @@ export function mergeLearnedRoutes(input: { // Usable reported addresses, by origin. const reported = new Map(); + const tailscaleOrigins = new Set(); for (const endpoint of input.reported) { let url: URL; try { @@ -257,6 +276,7 @@ export function mergeLearnedRoutes(input: { // A loopback address names whichever device opens it, never the server. if (isLocalLoopbackHost(url.hostname)) continue; reported.set(url.origin, url); + if (endpoint.kind === "tailnet") tailscaleOrigins.add(url.origin); } const normalized = (url: string) => url.replace(/\/+$/, ""); const known = new Set( @@ -265,12 +285,18 @@ export function mergeLearnedRoutes(input: { return url === null || isLearned(route) ? [] : [normalized(url)]; }), ); - const kept = saved.filter((route) => { - if (!isLearned(route)) return true; + const kept = saved.flatMap((route): ReadonlyArray => { const url = routeHttpBaseUrl(route); - if (url === null || !reported.has(normalized(url)) || known.has(normalized(url))) return false; + if (!isLearned(route)) { + return [ + url === null || !reported.has(normalized(url)) + ? route + : withNetwork(route, tailscaleOrigins.has(normalized(url))), + ]; + } + if (url === null || !reported.has(normalized(url)) || known.has(normalized(url))) return []; known.add(normalized(url)); - return true; + return [withNetwork(route, tailscaleOrigins.has(normalized(url)))]; }); let next: ReadonlyArray = kept; for (const url of reported.values()) { @@ -296,6 +322,7 @@ export function mergeLearnedRoutes(input: { wsBaseUrl: `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}/`, learned: true, ...(authorization === undefined ? {} : { authorization }), + ...(tailscaleOrigins.has(url.origin) ? { network: "tailscale" as const } : {}), }), ), }); @@ -303,11 +330,42 @@ export function mergeLearnedRoutes(input: { // Compare addresses too: a scheme or port change keeps no id stable. const signature = (routes: ReadonlyArray) => routes - .map((route) => `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""}`) + .map( + (route) => + `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""} ${connectionRouteKind(route)}`, + ) .join("\n"); return signature(saved) === signature(next) ? null : next; } +/** A direct address the server reports, as `ServerDirectEndpoint`. */ +export interface ReportedEndpoint { + readonly httpBaseUrl: string; + readonly kind?: string; +} + +/** The direct route with its Tailscale mark set or cleared; the same route when unchanged. */ +function withNetwork(route: ConnectionRoute, tailscale: boolean): ConnectionRoute { + const profile = Option.getOrNull(route.profile); + if (profile?._tag !== "BearerConnectionProfile") return route; + if ((profile.network === "tailscale") === tailscale) return route; + return { + target: route.target, + profile: Option.some( + new BearerConnectionProfile({ + connectionId: profile.connectionId, + environmentId: profile.environmentId, + label: profile.label, + httpBaseUrl: profile.httpBaseUrl, + wsBaseUrl: profile.wsBaseUrl, + ...(profile.learned === undefined ? {} : { learned: profile.learned }), + ...(profile.authorization === undefined ? {} : { authorization: profile.authorization }), + ...(tailscale ? { network: "tailscale" as const } : {}), + }), + ), + }; +} + /** * A learned bearer route borrows the credential of the route it was learned * from, so its id points back at that credential's owner. diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index b710718e32ad6307faf16b94a1e0a26d2f93d697..67f8daae691ab5d592e1801326eb98c3ce1ba939 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -30,7 +30,12 @@ import * as RpcSession from "../rpc/session.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; import * as ConnectionWakeups from "./wakeups.ts"; -import { connectionRouteId, connectionRoutes, entryWithRoutes } from "./routes.ts"; +import { + connectionRouteId, + connectionRoutes, + entryWithRoutes, + type ReportedEndpoint, +} from "./routes.ts"; const RETRY_BASE_DELAY_MS = 1_000; const RETRY_MAX_DELAY_MS = 300_000; @@ -120,7 +125,7 @@ export interface EnvironmentSupervisorOptions { */ readonly learnRoutes?: (input: { readonly activeRoute: ConnectionRoute; - readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>; + readonly reported: ReadonlyArray; }) => Effect.Effect>; } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e9473dee1df21c10cf5882e3d6578f50b5d6bc71..5855477855d19b1e040070317a5477173b2c8126 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -632,6 +632,10 @@ export function environmentThemeFileHasColors(file: EnvironmentThemeFile): boole ); } +/** + * "tailnet" when the address is on this machine's Tailscale interface, "lan" + * for any other private address, including other VPNs in 100.64.0.0/10. + */ export const ServerDirectEndpointKind = Schema.Literals(["lan", "tailnet"]); export type ServerDirectEndpointKind = typeof ServerDirectEndpointKind.Type; diff --git a/packages/shared/src/hostClassification.ts b/packages/shared/src/hostClassification.ts index 3f6dee4bf9dacae18f69dc451ff72d2198b3bbfa..2f0d8a119c8f333ef4d3c33e0142cbde6e1467dc 100644 --- a/packages/shared/src/hostClassification.ts +++ b/packages/shared/src/hostClassification.ts @@ -82,18 +82,26 @@ export const isLocalLoopbackHost = (host: string): boolean => { }; /** - * A Tailscale address: a MagicDNS name, the 100.64.0.0/10 range Tailscale - * assigns, or its IPv6 range fd7a:115c:a1e0::/48. + * A name or address only Tailscale uses: a MagicDNS name or its IPv6 range + * fd7a:115c:a1e0::/48. Its IPv4 addresses come from the shared 100.64.0.0/10 + * range, so they prove nothing on their own. */ export const isTailnetHost = (host: string): boolean => { const normalized = normalizeHostname(host); if (normalized.endsWith(".ts.net")) return true; - const parts = parseIpv4Address(normalized); - if (parts !== null) return parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127; const ipv6 = parseIpv6Address(normalized); return ipv6 !== null && ipv6PrefixMatches(ipv6, [0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 0], 48); }; +/** + * An IPv4 address in 100.64.0.0/10. Tailscale, Cloudflare WARP and Mesh, other + * VPNs, and carrier-grade NAT all assign from this range. + */ +export const isSharedAddressSpaceHost = (host: string): boolean => { + const parts = parseIpv4Address(host); + return parts !== null && parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127; +}; + export const isPrivateNetworkHost = (host: string): boolean => { const normalized = normalizeHostname(host); if ( -- 2.54.0