authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-10-09 17:26:05+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 17:26:05+05:30
logec80933ac8cd02fec5c97b342462ccc9567cdb1e
treee6edda7c12c84f0870db7f352f06acc9b03b4700
parent101f8b2f55df50d4f8d70ed8e6b8e0edf71a2f31
signature Signed by PGP key B5690EEEBB952194

fix(connect): Cloudflare and other VPN addresses no longer show as Tailscale (#17158)

Co-authored-by: PR Batch Tester <agent@local.test> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

12 files changed, 286 insertions(+), 42 deletions(-)

apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx+1
......@@ -37,6 +37,7 @@ const ROUTE_ICONS: Record<ConnectionRouteKind, AppSymbolName> = {
3737 loopback: "desktopcomputer",
3838 lan: "wifi",
3939 tailnet: "point.3.connected.trianglepath.dotted",
40 vpn: { ios: "lock.shield", android: "lock" },
4041 public: "globe",
4142 ssh: "terminal",
4243};
apps/server/src/environment/DirectEndpoints.test.ts+37
......@@ -54,6 +54,7 @@ const INTERFACES: ReturnType<typeof NodeOS.networkInterfaces> = {
5454 cidr: "203.0.113.20/24",
5555 },
5656 ],
57 // Tailscale on macOS: an anonymous utun that also carries a Tailscale IPv6 address.
5758 utun4: [
5859 {
5960 address: "100.101.102.103",
......@@ -63,9 +64,30 @@ const INTERFACES: ReturnType<typeof NodeOS.networkInterfaces> = {
6364 internal: false,
6465 cidr: "100.101.102.103/32",
6566 },
67 {
68 address: "fd7a:115c:a1e0::1",
69 netmask: "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
70 family: "IPv6",
71 mac: "00:00:00:00:00:00",
72 internal: false,
73 cidr: "fd7a:115c:a1e0::1/128",
74 scopeid: 0,
75 },
6676 ],
6777};
6878
79/** A VPN interface with an address from the same 100.64.0.0/10 range. */
80const vpnInterface = (address: string) => [
81 {
82 address,
83 netmask: "255.255.255.255",
84 family: "IPv4" as const,
85 mac: "00:00:00:00:00:00",
86 internal: false,
87 cidr: `${address}/32`,
88 },
89];
90
6991const virtualInterface = (address: string) => [
7092 {
7193 address,
......@@ -111,6 +133,21 @@ describe("resolveBoundEndpoints", () => {
111133 ]);
112134 });
113135
136 it("tags only addresses on Tailscale's interface as tailnet, not other VPNs in its range", () => {
137 const interfaces = {
138 en0: INTERFACES.en0,
139 tailscale0: vpnInterface("100.70.1.2"),
140 CloudflareWARP: vpnInterface("100.96.0.1"),
141 utun7: vpnInterface("100.85.0.1"),
142 };
143 expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces })).toEqual([
144 { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" },
145 { kind: "tailnet", httpBaseUrl: "http://100.70.1.2:3773/" },
146 { kind: "lan", httpBaseUrl: "http://100.96.0.1:3773/" },
147 { kind: "lan", httpBaseUrl: "http://100.85.0.1:3773/" },
148 ]);
149 });
150
114151 it("lists only the bound address for a specific bind", () => {
115152 expect(
116153 resolveBoundEndpoints({ host: "100.101.102.103", port: 3773, interfaces: INTERFACES }),
apps/server/src/environment/DirectEndpoints.ts+26-8
......@@ -10,7 +10,6 @@
1010import type { ServerDirectEndpoint } from "@t3tools/contracts";
1111import {
1212 buildTailscaleHttpsBaseUrl,
13 isTailscaleIpv4Address,
1413 probeTailscaleHttpsEndpoint,
1514 readTailscaleStatus,
1615} from "@t3tools/tailscale";
......@@ -24,7 +23,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
2423import * as NodeNet from "node:net";
2524import * as NodeOS from "node:os";
2625
27import { isPrivateNetworkHost } from "@t3tools/shared/hostClassification";
26import { isPrivateNetworkHost, isTailnetHost } from "@t3tools/shared/hostClassification";
2827
2928import * as ServerConfig from "../config.ts";
3029import { formatHostForUrl, isLoopbackHost, isWildcardHost } from "../startupAccess.ts";
......@@ -46,16 +45,17 @@ export class DirectEndpoints extends Context.Service<
4645>()("t3/environment/DirectEndpoints") {}
4746
4847/**
49 * Only numeric private-network and tailnet IPv4 addresses are reported. These
50 * routes are plain HTTP and carry the client's credential, so a public address
51 * would send it across the internet unencrypted, and a name (`server.local`)
52 * can resolve to a different machine on each client's network.
48 * Only numeric private-network IPv4 addresses, including VPN ranges such as
49 * 100.64.0.0/10, are reported. These routes are plain HTTP and carry the
50 * client's credential, so a public address would send it across the internet
51 * unencrypted, and a name (`server.local`) can resolve to a different machine
52 * on each client's network.
5353 */
5454const isAdvertisableAddress = (address: string): boolean =>
5555 NodeNet.isIPv4(address) &&
5656 !address.startsWith("127.") &&
5757 !address.startsWith("169.254.") &&
58 (isTailscaleIpv4Address(address) || isPrivateNetworkHost(address));
58 isPrivateNetworkHost(address);
5959
6060/**
6161 * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and
......@@ -66,6 +66,19 @@ const isAdvertisableAddress = (address: string): boolean =>
6666const VIRTUAL_INTERFACE =
6767 /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge1\d\d)/;
6868
69/**
70 * Tailscale's own interface: `tailscale0` on Linux, "Tailscale" on Windows.
71 * macOS names every tunnel `utunN`, so there it is the one that also carries
72 * an address in Tailscale's IPv6 range. Cloudflare WARP and other VPNs assign
73 * from the same IPv4 range, so the IPv4 address alone proves nothing.
74 */
75const isTailscaleInterface = (
76 name: string,
77 entries: ReadonlyArray<NodeOS.NetworkInterfaceInfo>,
78): boolean =>
79 /^tailscale/i.test(name) ||
80 entries.some((entry) => entry.family === "IPv6" && isTailnetHost(entry.address));
81
6982/**
7083 * Plain HTTP endpoints for the private addresses a server bound to `host`
7184 * accepts. IPv4 only: link-local and temporary IPv6 addresses change too
......@@ -77,6 +90,11 @@ export function resolveBoundEndpoints(input: {
7790 readonly interfaces: NetworkInterfacesMap;
7891}): ReadonlyArray<ServerDirectEndpoint> {
7992 if (isLoopbackHost(input.host)) return [];
93 const tailscaleAddresses = new Set(
94 Object.entries(input.interfaces).flatMap(([name, entries = []]) =>
95 isTailscaleInterface(name, entries) ? entries.map((entry) => entry.address) : [],
96 ),
97 );
8098 const addresses = isWildcardHost(input.host)
8199 ? Object.entries(input.interfaces)
82100 .flatMap(([name, entries]) => (VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? [])))
......@@ -87,7 +105,7 @@ export function resolveBoundEndpoints(input: {
87105 .map((entry) => entry.address)
88106 : [input.host!].filter(isAdvertisableAddress);
89107 return [...new Set(addresses)].map((address) => ({
90 kind: isTailscaleIpv4Address(address) ? "tailnet" : "lan",
108 kind: tailscaleAddresses.has(address) ? "tailnet" : "lan",
91109 httpBaseUrl: `http://${formatHostForUrl(address)}:${input.port}/`,
92110 }));
93111}
docs/user/remote-access.md+8-5
......@@ -64,11 +64,14 @@ another link to share.
6464
6565### Reach one machine several ways
6666
67A machine can have more than one route: LAN, Tailscale, a public URL, SSH, or
68T3 Connect. To add one, choose **Add route** in the machine's route list, or
69next to it in the T3 Connect list. Pairing the same machine again over another
70address also adds a route instead of a second machine. A new route is placed by
71speed, in that order, and you can reorder routes at any time.
67A machine can have more than one route: LAN, Tailscale, another VPN, a public
68URL, SSH, or T3 Connect. Tailscale shares its `100.64.0.0/10` address range with
69other VPNs such as Cloudflare WARP, so an address in that range shows as VPN
70unless the machine confirms it is on Tailscale. To add a route, choose **Add
71route** in the machine's route list, or next to it in the T3 Connect list.
72Pairing the same machine again over another address also adds a route instead
73of a second machine. A new route is placed by speed, in that order, and you can
74reorder routes at any time.
7275
7376While connected through T3 Connect or a paired address, T3 Code also learns the
7477machine's current LAN and Tailscale addresses and adds them as routes, so
packages/client-runtime/src/connection/catalog.ts+5
......@@ -33,6 +33,11 @@ export class BearerConnectionProfile extends Schema.TaggedClass<BearerConnection
3333 * Connect credential instead of a stored bearer token.
3434 */
3535 authorization: Schema.optionalKey(Schema.Literal("t3-connect")),
36 /**
37 * "tailscale" on a learned route the server found on its Tailscale
38 * interface. A bare 100.64.0.0/10 address could belong to any VPN.
39 */
40 network: Schema.optionalKey(Schema.Literal("tailscale")),
3641 },
3742) {}
3843
packages/client-runtime/src/connection/registry.test.ts+50-2
......@@ -4,6 +4,7 @@ import {
44 type OrchestrationV2ShellSnapshot,
55 ORCHESTRATION_PROTOCOL_VERSION,
66 type ExecutionEnvironmentDescriptor,
7 type ServerConfig,
78} from "@t3tools/contracts";
89import { describe, expect, it } from "@effect/vitest";
910import * as Context from "effect/Context";
......@@ -40,7 +41,7 @@ import * as Connectivity from "./connectivity.ts";
4041import * as ConnectionCredentialStore from "./credentialStore.ts";
4142import * as ConnectionDriver from "./driver.ts";
4243import type { RouteCheck } from "./driver.ts";
43import { connectionRouteId } from "./routes.ts";
44import { connectionRouteId, connectionRouteKind } from "./routes.ts";
4445import {
4546 ConnectionTransientError,
4647 ConnectionBlockedError,
......@@ -163,6 +164,8 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
163164 target: ConnectionTarget,
164165 ) => Effect.Effect<ConnectionBlockedError | undefined>;
165166 readonly initialDisabled?: ReadonlyArray<EnvironmentId>;
167 /** Direct addresses the connected server reports. */
168 readonly directEndpoints?: ServerConfig["directEndpoints"];
166169 },
167170) {
168171 const storedTargets = yield* Ref.make<ReadonlyArray<ConnectionTarget>>(initialTargets);
......@@ -409,7 +412,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
409412 const session = yield* Effect.acquireRelease(
410413 Effect.succeed({
411414 client: {} as RpcSession.RpcSession["client"],
412 initialConfig: Effect.die(new Error("Config is not used by registry tests.")),
415 initialConfig:
416 options?.directEndpoints === undefined
417 ? Effect.die(new Error("Config is not used by registry tests."))
418 : Effect.succeed({ directEndpoints: options.directEndpoints } as ServerConfig),
413419 subscribeServerConfig: () =>
414420 Stream.die(new Error("Config is not used by registry tests.")),
415421 ready: Effect.void,
......@@ -2067,6 +2073,48 @@ describe("EnvironmentRegistry routes", () => {
20672073 }),
20682074 );
20692075
2076 it.effect("saves the Tailscale mark when the server reports a learned address as tailnet", () =>
2077 Effect.gen(function* () {
2078 const httpBaseUrl = "http://100.101.102.103:3773/";
2079 const learnedId = `learned:${LAN_TARGET.environmentId}:http://100.101.102.103:3773@${LAN_TARGET.connectionId}`;
2080 const learned = new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId });
2081 // Learned by an earlier build, which saved no network.
2082 const learnedProfile = new BearerConnectionProfile({
2083 connectionId: learnedId,
2084 environmentId: LAN_TARGET.environmentId,
2085 label: LAN_TARGET.label,
2086 httpBaseUrl,
2087 wsBaseUrl: "ws://100.101.102.103:3773/",
2088 learned: true,
2089 });
2090 const harness = yield* makeHarness(
2091 [LAN_TARGET, learned],
2092 [LAN_PROFILE, learnedProfile],
2093 [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]],
2094 {
2095 directEndpoints: [
2096 { kind: "lan", httpBaseUrl: LAN_PROFILE.httpBaseUrl },
2097 { kind: "tailnet", httpBaseUrl },
2098 ],
2099 },
2100 );
2101 yield* Effect.gen(function* () {
2102 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
2103 yield* registry.start;
2104 yield* SubscriptionRef.changes(registry.entries).pipe(
2105 Stream.map((entries) => entries.get(LAN_TARGET.environmentId)?.alternateRoutes?.[0]),
2106 Stream.filter((route) => route !== undefined && connectionRouteKind(route) === "tailnet"),
2107 Stream.runHead,
2108 );
2109 expect((yield* Ref.get(harness.storedProfiles)).get(learnedId)).toMatchObject({
2110 network: "tailscale",
2111 learned: true,
2112 httpBaseUrl,
2113 });
2114 }).pipe(Effect.provide(harness.layer), Effect.scoped);
2115 }),
2116 );
2117
20702118 it.effect("drops a learned route whose profile was not saved instead of duplicating it", () =>
20712119 Effect.gen(function* () {
20722120 const learnedId = `learned:${LAN_TARGET.environmentId}:100.64.0.9:3773@${LAN_TARGET.connectionId}`;
packages/client-runtime/src/connection/registry.ts+17-8
......@@ -49,10 +49,10 @@ import {
4949 connectionRoutes,
5050 entryWithRoutes,
5151 findRouteToSameAddress,
52 isLearned,
5352 mergeLearnedRoutes,
5453 routesAfterRemoving,
5554 upsertRoute,
55 type ReportedEndpoint,
5656} from "./routes.ts";
5757
5858const isSshConnectionProfile = Schema.is(SshConnectionProfile);
......@@ -891,7 +891,7 @@ export const make = Effect.gen(function* () {
891891 const learnRoutes = Effect.fn("EnvironmentRegistry.learnRoutes")(function* (input: {
892892 readonly environmentId: EnvironmentId;
893893 readonly activeRoute: ConnectionRoute;
894 readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>;
894 readonly reported: ReadonlyArray<ReportedEndpoint>;
895895 }) {
896896 return yield* withLeaseLock(
897897 input.environmentId,
......@@ -909,15 +909,24 @@ export const make = Effect.gen(function* () {
909909 });
910910 if (routes === null) return Option.none<ConnectionCatalogEntry>();
911911 const next = entryWithRoutes(entry, routes);
912 // A learned route owns its profile (address and authorization); the
913 // credential stays with the route it borrows from.
914 const previousIds = new Set(
915 connectionRoutes(entry).map((route) => connectionRouteId(route.target)),
912 // Save new learned profiles and any whose Tailscale mark changed. A
913 // learned route owns its profile; the credential stays with the route
914 // it borrows from.
915 const previousProfiles = new Map(
916 connectionRoutes(entry).map((route) => [
917 connectionRouteId(route.target),
918 Option.getOrNull(route.profile),
919 ]),
916920 );
917921 for (const route of routes) {
918 if (!isLearned(route) || previousIds.has(connectionRouteId(route.target))) continue;
919922 const profile = Option.getOrNull(route.profile);
920 if (profile !== null) yield* profiles.put(profile);
923 if (
924 profile === null ||
925 previousProfiles.get(connectionRouteId(route.target)) === profile
926 ) {
927 continue;
928 }
929 yield* profiles.put(profile);
921930 }
922931 yield* registrations.setRoutes(input.environmentId, persistedRoutes(next));
923932 // Update the lease in place: the live session already works, and
packages/client-runtime/src/connection/routes.test.ts+49-1
......@@ -54,7 +54,9 @@ const PUBLIC = direct("public", "https://desk.example.com/");
5454describe("connection routes", () => {
5555 it("classifies direct routes by address", () => {
5656 expect(connectionRouteKind(LAN)).toBe("lan");
57 expect(connectionRouteKind(direct("ip", "http://100.101.102.103:3773/"))).toBe("tailnet");
57 // Tailscale, Cloudflare WARP and Mesh, and other VPNs share 100.64.0.0/10.
58 expect(connectionRouteKind(direct("ip", "http://100.101.102.103:3773/"))).toBe("vpn");
59 expect(connectionRouteLabel(direct("mesh", "http://100.96.0.1:3773/"))).toBe("VPN");
5860 expect(connectionRouteKind(TAILNET)).toBe("tailnet");
5961 expect(connectionRouteKind(PUBLIC)).toBe("public");
6062 expect(connectionRouteKind(direct("lo", "http://127.0.0.1:3773/"))).toBe("loopback");
......@@ -156,6 +158,52 @@ describe("learned routes", () => {
156158 ]);
157159 });
158160
161 it("labels a learned address Tailscale only while the server reports it as tailnet", () => {
162 const tailscale = { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" };
163 const mesh = { kind: "lan", httpBaseUrl: "http://100.96.0.1:3773/" };
164 const first = mergeLearnedRoutes({
165 entry: relayOnly,
166 activeRoute: RELAY,
167 reported: [tailscale, mesh],
168 allowInsecure: true,
169 })!;
170 expect(first.map(connectionRouteLabel)).toEqual(["Tailscale", "VPN", "T3 Connect"]);
171
172 // The server later finds the address is not on its Tailscale interface.
173 const corrected = mergeLearnedRoutes({
174 entry: entryWithRoutes(relayOnly, first),
175 activeRoute: RELAY,
176 reported: [{ ...tailscale, kind: "lan" }, mesh],
177 allowInsecure: true,
178 })!;
179 expect(ids(corrected)).toEqual(ids(first));
180 expect(corrected.map(connectionRouteLabel)).toEqual(["VPN", "VPN", "T3 Connect"]);
181 expect(
182 mergeLearnedRoutes({
183 entry: entryWithRoutes(relayOnly, corrected),
184 activeRoute: RELAY,
185 reported: [{ ...tailscale, kind: "lan" }, mesh],
186 allowInsecure: true,
187 }),
188 ).toBeNull();
189 });
190
191 it("labels a paired numeric Tailscale address Tailscale once the server confirms it", () => {
192 const paired = direct("paired", "http://100.101.102.103:3773/");
193 const other = direct("other", "http://100.96.0.1:3773/");
194 const entry = entryWithRoutes(relayOnly, [paired, other, RELAY]);
195 expect(connectionRouteLabel(paired)).toBe("VPN");
196 const confirmed = mergeLearnedRoutes({
197 entry,
198 activeRoute: paired,
199 reported: [{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }],
200 allowInsecure: true,
201 })!;
202 // Same routes in the same order; only the label of the confirmed one changes.
203 expect(ids(confirmed)).toEqual(["paired", "other", "relay"]);
204 expect(confirmed.map(connectionRouteLabel)).toEqual(["Tailscale", "VPN", "T3 Connect"]);
205 });
206
159207 it("leaves user routes alone and does not learn an address already saved", () => {
160208 const entry: ConnectionCatalogEntry = {
161209 target: LAN.target,
packages/client-runtime/src/connection/routes.ts+70-12
......@@ -1,6 +1,7 @@
11import {
22 isLocalLoopbackHost,
33 isPrivateNetworkHost,
4 isSharedAddressSpaceHost,
45 isTailnetHost,
56} from "@t3tools/shared/hostClassification";
67import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
......@@ -15,12 +16,19 @@ import { BearerConnectionTarget, type ConnectionTarget } from "./model.ts";
1516
1617/**
1718 * A saved environment can hold several routes: T3 Connect, direct URLs (LAN,
18 * tailnet, public), and SSH. The client connects over the first route in
19 * tailnet, other VPNs, public), and SSH. The client connects over the first route in
1920 * preference order that answers as the expected environment, and moves back
2021 * to a better one when it becomes reachable again.
2122 */
2223
23export type ConnectionRouteKind = "relay" | "loopback" | "lan" | "tailnet" | "public" | "ssh";
24export type ConnectionRouteKind =
25 | "relay"
26 | "loopback"
27 | "lan"
28 | "tailnet"
29 | "vpn"
30 | "public"
31 | "ssh";
2432
2533/** An environment has at most one T3 Connect route, so it needs no per-route id. */
2634export const RELAY_ROUTE_ID = "relay";
......@@ -96,7 +104,11 @@ export function connectionRouteKind(route: ConnectionRoute): ConnectionRouteKind
96104 const hostname = routeHostname(route);
97105 if (hostname === null) return "public";
98106 if (isLocalLoopbackHost(hostname)) return "loopback";
99 if (isTailnetHost(hostname)) return "tailnet";
107 const profile = Option.getOrNull(route.profile);
108 const tailscale =
109 profile?._tag === "BearerConnectionProfile" && profile.network === "tailscale";
110 if (tailscale || isTailnetHost(hostname)) return "tailnet";
111 if (isSharedAddressSpaceHost(hostname)) return "vpn";
100112 return isPrivateNetworkHost(hostname) ? "lan" : "public";
101113 }
102114 }
......@@ -106,6 +118,7 @@ const ROUTE_KIND_RANK: Record<ConnectionRouteKind, number> = {
106118 loopback: 0,
107119 lan: 1,
108120 tailnet: 2,
121 vpn: 2,
109122 public: 3,
110123 ssh: 4,
111124 relay: 5,
......@@ -166,7 +179,7 @@ function routeAddressKey(route: ConnectionRoute): string | null {
166179 }
167180}
168181
169/** Short user-facing route description: "LAN", "Tailscale", "T3 Connect", a URL, or an SSH host. */
182/** Short user-facing route description: "LAN", "Tailscale", "VPN", "T3 Connect", a URL, or an SSH host. */
170183export function connectionRouteLabel(route: ConnectionRoute): string {
171184 switch (connectionRouteKind(route)) {
172185 case "relay":
......@@ -177,6 +190,8 @@ export function connectionRouteLabel(route: ConnectionRoute): string {
177190 return "LAN";
178191 case "tailnet":
179192 return "Tailscale";
193 case "vpn":
194 return "VPN";
180195 case "ssh": {
181196 const profile = Option.getOrNull(route.profile);
182197 return profile?._tag === "SshConnectionProfile"
......@@ -212,13 +227,16 @@ export function hasRelayRoute(
212227 * in use: the paired token for a bearer route, the T3 Connect credential for
213228 * relay. A learned route the server still reports keeps its place, so the
214229 * user's order holds; one it no longer reports is dropped, so a changed LAN
215 * address replaces the old one. Routes the user saved are never touched, and
216 * an address already saved is not learned twice.
230 * address replaces the old one. Routes the user saved are never removed or
231 * moved, and an address already saved is not learned twice. Any direct route
232 * the server reports is marked as Tailscale while the server finds it on its
233 * Tailscale interface, so a route paired by its numeric Tailscale address keeps
234 * that label.
217235 */
218236export function mergeLearnedRoutes(input: {
219237 readonly entry: ConnectionCatalogEntry;
220238 readonly activeRoute: ConnectionRoute;
221 readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>;
239 readonly reported: ReadonlyArray<ReportedEndpoint>;
222240 /** Plain HTTP routes are unusable from an HTTPS page (mixed content). */
223241 readonly allowInsecure: boolean;
224242}): ReadonlyArray<ConnectionRoute> | null {
......@@ -245,6 +263,7 @@ export function mergeLearnedRoutes(input: {
245263
246264 // Usable reported addresses, by origin.
247265 const reported = new Map<string, URL>();
266 const tailscaleOrigins = new Set<string>();
248267 for (const endpoint of input.reported) {
249268 let url: URL;
250269 try {
......@@ -257,6 +276,7 @@ export function mergeLearnedRoutes(input: {
257276 // A loopback address names whichever device opens it, never the server.
258277 if (isLocalLoopbackHost(url.hostname)) continue;
259278 reported.set(url.origin, url);
279 if (endpoint.kind === "tailnet") tailscaleOrigins.add(url.origin);
260280 }
261281 const normalized = (url: string) => url.replace(/\/+$/, "");
262282 const known = new Set(
......@@ -265,12 +285,18 @@ export function mergeLearnedRoutes(input: {
265285 return url === null || isLearned(route) ? [] : [normalized(url)];
266286 }),
267287 );
268 const kept = saved.filter((route) => {
269 if (!isLearned(route)) return true;
288 const kept = saved.flatMap((route): ReadonlyArray<ConnectionRoute> => {
270289 const url = routeHttpBaseUrl(route);
271 if (url === null || !reported.has(normalized(url)) || known.has(normalized(url))) return false;
290 if (!isLearned(route)) {
291 return [
292 url === null || !reported.has(normalized(url))
293 ? route
294 : withNetwork(route, tailscaleOrigins.has(normalized(url))),
295 ];
296 }
297 if (url === null || !reported.has(normalized(url)) || known.has(normalized(url))) return [];
272298 known.add(normalized(url));
273 return true;
299 return [withNetwork(route, tailscaleOrigins.has(normalized(url)))];
274300 });
275301 let next: ReadonlyArray<ConnectionRoute> = kept;
276302 for (const url of reported.values()) {
......@@ -296,6 +322,7 @@ export function mergeLearnedRoutes(input: {
296322 wsBaseUrl: `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}/`,
297323 learned: true,
298324 ...(authorization === undefined ? {} : { authorization }),
325 ...(tailscaleOrigins.has(url.origin) ? { network: "tailscale" as const } : {}),
299326 }),
300327 ),
301328 });
......@@ -303,11 +330,42 @@ export function mergeLearnedRoutes(input: {
303330 // Compare addresses too: a scheme or port change keeps no id stable.
304331 const signature = (routes: ReadonlyArray<ConnectionRoute>) =>
305332 routes
306 .map((route) => `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""}`)
333 .map(
334 (route) =>
335 `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""} ${connectionRouteKind(route)}`,
336 )
307337 .join("\n");
308338 return signature(saved) === signature(next) ? null : next;
309339}
310340
341/** A direct address the server reports, as `ServerDirectEndpoint`. */
342export interface ReportedEndpoint {
343 readonly httpBaseUrl: string;
344 readonly kind?: string;
345}
346
347/** The direct route with its Tailscale mark set or cleared; the same route when unchanged. */
348function withNetwork(route: ConnectionRoute, tailscale: boolean): ConnectionRoute {
349 const profile = Option.getOrNull(route.profile);
350 if (profile?._tag !== "BearerConnectionProfile") return route;
351 if ((profile.network === "tailscale") === tailscale) return route;
352 return {
353 target: route.target,
354 profile: Option.some(
355 new BearerConnectionProfile({
356 connectionId: profile.connectionId,
357 environmentId: profile.environmentId,
358 label: profile.label,
359 httpBaseUrl: profile.httpBaseUrl,
360 wsBaseUrl: profile.wsBaseUrl,
361 ...(profile.learned === undefined ? {} : { learned: profile.learned }),
362 ...(profile.authorization === undefined ? {} : { authorization: profile.authorization }),
363 ...(tailscale ? { network: "tailscale" as const } : {}),
364 }),
365 ),
366 };
367}
368
311369/**
312370 * A learned bearer route borrows the credential of the route it was learned
313371 * from, so its id points back at that credential's owner.
packages/client-runtime/src/connection/supervisor.ts+7-2
......@@ -30,7 +30,12 @@ import * as RpcSession from "../rpc/session.ts";
3030import { safeErrorLogAttributes } from "../errors/safeLog.ts";
3131import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
3232import * as ConnectionWakeups from "./wakeups.ts";
33import { connectionRouteId, connectionRoutes, entryWithRoutes } from "./routes.ts";
33import {
34 connectionRouteId,
35 connectionRoutes,
36 entryWithRoutes,
37 type ReportedEndpoint,
38} from "./routes.ts";
3439
3540const RETRY_BASE_DELAY_MS = 1_000;
3641const RETRY_MAX_DELAY_MS = 300_000;
......@@ -120,7 +125,7 @@ export interface EnvironmentSupervisorOptions {
120125 */
121126 readonly learnRoutes?: (input: {
122127 readonly activeRoute: ConnectionRoute;
123 readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>;
128 readonly reported: ReadonlyArray<ReportedEndpoint>;
124129 }) => Effect.Effect<Option.Option<ConnectionCatalogEntry>>;
125130}
126131
packages/contracts/src/server.ts+4
......@@ -632,6 +632,10 @@ export function environmentThemeFileHasColors(file: EnvironmentThemeFile): boole
632632 );
633633}
634634
635/**
636 * "tailnet" when the address is on this machine's Tailscale interface, "lan"
637 * for any other private address, including other VPNs in 100.64.0.0/10.
638 */
635639export const ServerDirectEndpointKind = Schema.Literals(["lan", "tailnet"]);
636640export type ServerDirectEndpointKind = typeof ServerDirectEndpointKind.Type;
637641
packages/shared/src/hostClassification.ts+12-4
......@@ -82,18 +82,26 @@ export const isLocalLoopbackHost = (host: string): boolean => {
8282};
8383
8484/**
85 * A Tailscale address: a MagicDNS name, the 100.64.0.0/10 range Tailscale
86 * assigns, or its IPv6 range fd7a:115c:a1e0::/48.
85 * A name or address only Tailscale uses: a MagicDNS name or its IPv6 range
86 * fd7a:115c:a1e0::/48. Its IPv4 addresses come from the shared 100.64.0.0/10
87 * range, so they prove nothing on their own.
8788 */
8889export const isTailnetHost = (host: string): boolean => {
8990 const normalized = normalizeHostname(host);
9091 if (normalized.endsWith(".ts.net")) return true;
91 const parts = parseIpv4Address(normalized);
92 if (parts !== null) return parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127;
9392 const ipv6 = parseIpv6Address(normalized);
9493 return ipv6 !== null && ipv6PrefixMatches(ipv6, [0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 0], 48);
9594};
9695
96/**
97 * An IPv4 address in 100.64.0.0/10. Tailscale, Cloudflare WARP and Mesh, other
98 * VPNs, and carrier-grade NAT all assign from this range.
99 */
100export const isSharedAddressSpaceHost = (host: string): boolean => {
101 const parts = parseIpv4Address(host);
102 return parts !== null && parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127;
103};
104
97105export const isPrivateNetworkHost = (host: string): boolean => {
98106 const normalized = normalizeHostname(host);
99107 if (