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> = {...@@ -37,6 +37,7 @@ const ROUTE_ICONS: Record<ConnectionRouteKind, AppSymbolName> = {
37 loopback: "desktopcomputer",37 loopback: "desktopcomputer",
38 lan: "wifi",38 lan: "wifi",
39 tailnet: "point.3.connected.trianglepath.dotted",39 tailnet: "point.3.connected.trianglepath.dotted",
40 vpn: { ios: "lock.shield", android: "lock" },
40 public: "globe",41 public: "globe",
41 ssh: "terminal",42 ssh: "terminal",
42};43};
apps/server/src/environment/DirectEndpoints.test.ts+37
...@@ -54,6 +54,7 @@ const INTERFACES: ReturnType<typeof NodeOS.networkInterfaces> = {...@@ -54,6 +54,7 @@ const INTERFACES: ReturnType<typeof NodeOS.networkInterfaces> = {
54 cidr: "203.0.113.20/24",54 cidr: "203.0.113.20/24",
55 },55 },
56 ],56 ],
57 // Tailscale on macOS: an anonymous utun that also carries a Tailscale IPv6 address.
57 utun4: [58 utun4: [
58 {59 {
59 address: "100.101.102.103",60 address: "100.101.102.103",
...@@ -63,9 +64,30 @@ const INTERFACES: ReturnType<typeof NodeOS.networkInterfaces> = {...@@ -63,9 +64,30 @@ const INTERFACES: ReturnType<typeof NodeOS.networkInterfaces> = {
63 internal: false,64 internal: false,
64 cidr: "100.101.102.103/32",65 cidr: "100.101.102.103/32",
65 },66 },
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 },
66 ],76 ],
67};77};
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
69const virtualInterface = (address: string) => [91const virtualInterface = (address: string) => [
70 {92 {
71 address,93 address,
...@@ -111,6 +133,21 @@ describe("resolveBoundEndpoints", () => {...@@ -111,6 +133,21 @@ describe("resolveBoundEndpoints", () => {
111 ]);133 ]);
112 });134 });
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
114 it("lists only the bound address for a specific bind", () => {151 it("lists only the bound address for a specific bind", () => {
115 expect(152 expect(
116 resolveBoundEndpoints({ host: "100.101.102.103", port: 3773, interfaces: INTERFACES }),153 resolveBoundEndpoints({ host: "100.101.102.103", port: 3773, interfaces: INTERFACES }),
apps/server/src/environment/DirectEndpoints.ts+26-8
...@@ -10,7 +10,6 @@...@@ -10,7 +10,6 @@
10import type { ServerDirectEndpoint } from "@t3tools/contracts";10import type { ServerDirectEndpoint } from "@t3tools/contracts";
11import {11import {
12 buildTailscaleHttpsBaseUrl,12 buildTailscaleHttpsBaseUrl,
13 isTailscaleIpv4Address,
14 probeTailscaleHttpsEndpoint,13 probeTailscaleHttpsEndpoint,
15 readTailscaleStatus,14 readTailscaleStatus,
16} from "@t3tools/tailscale";15} from "@t3tools/tailscale";
...@@ -24,7 +23,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";...@@ -24,7 +23,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
24import * as NodeNet from "node:net";23import * as NodeNet from "node:net";
25import * as NodeOS from "node:os";24import * as NodeOS from "node:os";
2625
27import { isPrivateNetworkHost } from "@t3tools/shared/hostClassification";26import { isPrivateNetworkHost, isTailnetHost } from "@t3tools/shared/hostClassification";
2827
29import * as ServerConfig from "../config.ts";28import * as ServerConfig from "../config.ts";
30import { formatHostForUrl, isLoopbackHost, isWildcardHost } from "../startupAccess.ts";29import { formatHostForUrl, isLoopbackHost, isWildcardHost } from "../startupAccess.ts";
...@@ -46,16 +45,17 @@ export class DirectEndpoints extends Context.Service<...@@ -46,16 +45,17 @@ export class DirectEndpoints extends Context.Service<
46>()("t3/environment/DirectEndpoints") {}45>()("t3/environment/DirectEndpoints") {}
4746
48/**47/**
49 * Only numeric private-network and tailnet IPv4 addresses are reported. These48 * Only numeric private-network IPv4 addresses, including VPN ranges such as
50 * routes are plain HTTP and carry the client's credential, so a public address49 * 100.64.0.0/10, are reported. These routes are plain HTTP and carry the
51 * would send it across the internet unencrypted, and a name (`server.local`)50 * client's credential, so a public address would send it across the internet
52 * can resolve to a different machine on each client's network.51 * unencrypted, and a name (`server.local`) can resolve to a different machine
52 * on each client's network.
53 */53 */
54const isAdvertisableAddress = (address: string): boolean =>54const isAdvertisableAddress = (address: string): boolean =>
55 NodeNet.isIPv4(address) &&55 NodeNet.isIPv4(address) &&
56 !address.startsWith("127.") &&56 !address.startsWith("127.") &&
57 !address.startsWith("169.254.") &&57 !address.startsWith("169.254.") &&
58 (isTailscaleIpv4Address(address) || isPrivateNetworkHost(address));58 isPrivateNetworkHost(address);
5959
60/**60/**
61 * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and61 * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and
...@@ -66,6 +66,19 @@ const isAdvertisableAddress = (address: string): boolean =>...@@ -66,6 +66,19 @@ const isAdvertisableAddress = (address: string): boolean =>
66const VIRTUAL_INTERFACE =66const VIRTUAL_INTERFACE =
67 /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge1\d\d)/;67 /^(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
69/**82/**
70 * Plain HTTP endpoints for the private addresses a server bound to `host`83 * Plain HTTP endpoints for the private addresses a server bound to `host`
71 * accepts. IPv4 only: link-local and temporary IPv6 addresses change too84 * accepts. IPv4 only: link-local and temporary IPv6 addresses change too
...@@ -77,6 +90,11 @@ export function resolveBoundEndpoints(input: {...@@ -77,6 +90,11 @@ export function resolveBoundEndpoints(input: {
77 readonly interfaces: NetworkInterfacesMap;90 readonly interfaces: NetworkInterfacesMap;
78}): ReadonlyArray<ServerDirectEndpoint> {91}): ReadonlyArray<ServerDirectEndpoint> {
79 if (isLoopbackHost(input.host)) return [];92 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 );
80 const addresses = isWildcardHost(input.host)98 const addresses = isWildcardHost(input.host)
81 ? Object.entries(input.interfaces)99 ? Object.entries(input.interfaces)
82 .flatMap(([name, entries]) => (VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? [])))100 .flatMap(([name, entries]) => (VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? [])))
...@@ -87,7 +105,7 @@ export function resolveBoundEndpoints(input: {...@@ -87,7 +105,7 @@ export function resolveBoundEndpoints(input: {
87 .map((entry) => entry.address)105 .map((entry) => entry.address)
88 : [input.host!].filter(isAdvertisableAddress);106 : [input.host!].filter(isAdvertisableAddress);
89 return [...new Set(addresses)].map((address) => ({107 return [...new Set(addresses)].map((address) => ({
90 kind: isTailscaleIpv4Address(address) ? "tailnet" : "lan",108 kind: tailscaleAddresses.has(address) ? "tailnet" : "lan",
91 httpBaseUrl: `http://${formatHostForUrl(address)}:${input.port}/`,109 httpBaseUrl: `http://${formatHostForUrl(address)}:${input.port}/`,
92 }));110 }));
93}111}
docs/user/remote-access.md+8-5
...@@ -64,11 +64,14 @@ another link to share....@@ -64,11 +64,14 @@ another link to share.
6464
65### Reach one machine several ways65### Reach one machine several ways
6666
67A machine can have more than one route: LAN, Tailscale, a public URL, SSH, or67A machine can have more than one route: LAN, Tailscale, another VPN, a public
68T3 Connect. To add one, choose **Add route** in the machine's route list, or68URL, SSH, or T3 Connect. Tailscale shares its `100.64.0.0/10` address range with
69next to it in the T3 Connect list. Pairing the same machine again over another69other VPNs such as Cloudflare WARP, so an address in that range shows as VPN
70address also adds a route instead of a second machine. A new route is placed by70unless the machine confirms it is on Tailscale. To add a route, choose **Add
71speed, in that order, and you can reorder routes at any time.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
73While connected through T3 Connect or a paired address, T3 Code also learns the76While connected through T3 Connect or a paired address, T3 Code also learns the
74machine's current LAN and Tailscale addresses and adds them as routes, so77machine'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...@@ -33,6 +33,11 @@ export class BearerConnectionProfile extends Schema.TaggedClass<BearerConnection
33 * Connect credential instead of a stored bearer token.33 * Connect credential instead of a stored bearer token.
34 */34 */
35 authorization: Schema.optionalKey(Schema.Literal("t3-connect")),35 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")),
36 },41 },
37) {}42) {}
3843
packages/client-runtime/src/connection/registry.test.ts+50-2
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 type OrchestrationV2ShellSnapshot,4 type OrchestrationV2ShellSnapshot,
5 ORCHESTRATION_PROTOCOL_VERSION,5 ORCHESTRATION_PROTOCOL_VERSION,
6 type ExecutionEnvironmentDescriptor,6 type ExecutionEnvironmentDescriptor,
7 type ServerConfig,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import { describe, expect, it } from "@effect/vitest";9import { describe, expect, it } from "@effect/vitest";
9import * as Context from "effect/Context";10import * as Context from "effect/Context";
...@@ -40,7 +41,7 @@ import * as Connectivity from "./connectivity.ts";...@@ -40,7 +41,7 @@ import * as Connectivity from "./connectivity.ts";
40import * as ConnectionCredentialStore from "./credentialStore.ts";41import * as ConnectionCredentialStore from "./credentialStore.ts";
41import * as ConnectionDriver from "./driver.ts";42import * as ConnectionDriver from "./driver.ts";
42import type { RouteCheck } from "./driver.ts";43import type { RouteCheck } from "./driver.ts";
43import { connectionRouteId } from "./routes.ts";44import { connectionRouteId, connectionRouteKind } from "./routes.ts";
44import {45import {
45 ConnectionTransientError,46 ConnectionTransientError,
46 ConnectionBlockedError,47 ConnectionBlockedError,
...@@ -163,6 +164,8 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (...@@ -163,6 +164,8 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
163 target: ConnectionTarget,164 target: ConnectionTarget,
164 ) => Effect.Effect<ConnectionBlockedError | undefined>;165 ) => Effect.Effect<ConnectionBlockedError | undefined>;
165 readonly initialDisabled?: ReadonlyArray<EnvironmentId>;166 readonly initialDisabled?: ReadonlyArray<EnvironmentId>;
167 /** Direct addresses the connected server reports. */
168 readonly directEndpoints?: ServerConfig["directEndpoints"];
166 },169 },
167) {170) {
168 const storedTargets = yield* Ref.make<ReadonlyArray<ConnectionTarget>>(initialTargets);171 const storedTargets = yield* Ref.make<ReadonlyArray<ConnectionTarget>>(initialTargets);
...@@ -409,7 +412,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (...@@ -409,7 +412,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
409 const session = yield* Effect.acquireRelease(412 const session = yield* Effect.acquireRelease(
410 Effect.succeed({413 Effect.succeed({
411 client: {} as RpcSession.RpcSession["client"],414 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),
413 subscribeServerConfig: () =>419 subscribeServerConfig: () =>
414 Stream.die(new Error("Config is not used by registry tests.")),420 Stream.die(new Error("Config is not used by registry tests.")),
415 ready: Effect.void,421 ready: Effect.void,
...@@ -2067,6 +2073,48 @@ describe("EnvironmentRegistry routes", () => {...@@ -2067,6 +2073,48 @@ describe("EnvironmentRegistry routes", () => {
2067 }),2073 }),
2068 );2074 );
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
2070 it.effect("drops a learned route whose profile was not saved instead of duplicating it", () =>2118 it.effect("drops a learned route whose profile was not saved instead of duplicating it", () =>
2071 Effect.gen(function* () {2119 Effect.gen(function* () {
2072 const learnedId = `learned:${LAN_TARGET.environmentId}:100.64.0.9:3773@${LAN_TARGET.connectionId}`;2120 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 {...@@ -49,10 +49,10 @@ import {
49 connectionRoutes,49 connectionRoutes,
50 entryWithRoutes,50 entryWithRoutes,
51 findRouteToSameAddress,51 findRouteToSameAddress,
52 isLearned,
53 mergeLearnedRoutes,52 mergeLearnedRoutes,
54 routesAfterRemoving,53 routesAfterRemoving,
55 upsertRoute,54 upsertRoute,
55 type ReportedEndpoint,
56} from "./routes.ts";56} from "./routes.ts";
5757
58const isSshConnectionProfile = Schema.is(SshConnectionProfile);58const isSshConnectionProfile = Schema.is(SshConnectionProfile);
...@@ -891,7 +891,7 @@ export const make = Effect.gen(function* () {...@@ -891,7 +891,7 @@ export const make = Effect.gen(function* () {
891 const learnRoutes = Effect.fn("EnvironmentRegistry.learnRoutes")(function* (input: {891 const learnRoutes = Effect.fn("EnvironmentRegistry.learnRoutes")(function* (input: {
892 readonly environmentId: EnvironmentId;892 readonly environmentId: EnvironmentId;
893 readonly activeRoute: ConnectionRoute;893 readonly activeRoute: ConnectionRoute;
894 readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>;894 readonly reported: ReadonlyArray<ReportedEndpoint>;
895 }) {895 }) {
896 return yield* withLeaseLock(896 return yield* withLeaseLock(
897 input.environmentId,897 input.environmentId,
...@@ -909,15 +909,24 @@ export const make = Effect.gen(function* () {...@@ -909,15 +909,24 @@ export const make = Effect.gen(function* () {
909 });909 });
910 if (routes === null) return Option.none<ConnectionCatalogEntry>();910 if (routes === null) return Option.none<ConnectionCatalogEntry>();
911 const next = entryWithRoutes(entry, routes);911 const next = entryWithRoutes(entry, routes);
912 // A learned route owns its profile (address and authorization); the912 // Save new learned profiles and any whose Tailscale mark changed. A
913 // credential stays with the route it borrows from.913 // learned route owns its profile; the credential stays with the route
914 const previousIds = new Set(914 // it borrows from.
915 connectionRoutes(entry).map((route) => connectionRouteId(route.target)),915 const previousProfiles = new Map(
916 connectionRoutes(entry).map((route) => [
917 connectionRouteId(route.target),
918 Option.getOrNull(route.profile),
919 ]),
916 );920 );
917 for (const route of routes) {921 for (const route of routes) {
918 if (!isLearned(route) || previousIds.has(connectionRouteId(route.target))) continue;
919 const profile = Option.getOrNull(route.profile);922 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);
921 }930 }
922 yield* registrations.setRoutes(input.environmentId, persistedRoutes(next));931 yield* registrations.setRoutes(input.environmentId, persistedRoutes(next));
923 // Update the lease in place: the live session already works, and932 // 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/");...@@ -54,7 +54,9 @@ const PUBLIC = direct("public", "https://desk.example.com/");
54describe("connection routes", () => {54describe("connection routes", () => {
55 it("classifies direct routes by address", () => {55 it("classifies direct routes by address", () => {
56 expect(connectionRouteKind(LAN)).toBe("lan");56 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");
58 expect(connectionRouteKind(TAILNET)).toBe("tailnet");60 expect(connectionRouteKind(TAILNET)).toBe("tailnet");
59 expect(connectionRouteKind(PUBLIC)).toBe("public");61 expect(connectionRouteKind(PUBLIC)).toBe("public");
60 expect(connectionRouteKind(direct("lo", "http://127.0.0.1:3773/"))).toBe("loopback");62 expect(connectionRouteKind(direct("lo", "http://127.0.0.1:3773/"))).toBe("loopback");
...@@ -156,6 +158,52 @@ describe("learned routes", () => {...@@ -156,6 +158,52 @@ describe("learned routes", () => {
156 ]);158 ]);
157 });159 });
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
159 it("leaves user routes alone and does not learn an address already saved", () => {207 it("leaves user routes alone and does not learn an address already saved", () => {
160 const entry: ConnectionCatalogEntry = {208 const entry: ConnectionCatalogEntry = {
161 target: LAN.target,209 target: LAN.target,
packages/client-runtime/src/connection/routes.ts+70-12
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 isLocalLoopbackHost,2 isLocalLoopbackHost,
3 isPrivateNetworkHost,3 isPrivateNetworkHost,
4 isSharedAddressSpaceHost,
4 isTailnetHost,5 isTailnetHost,
5} from "@t3tools/shared/hostClassification";6} from "@t3tools/shared/hostClassification";
6import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";7import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
...@@ -15,12 +16,19 @@ import { BearerConnectionTarget, type ConnectionTarget } from "./model.ts";...@@ -15,12 +16,19 @@ import { BearerConnectionTarget, type ConnectionTarget } from "./model.ts";
1516
16/**17/**
17 * A saved environment can hold several routes: T3 Connect, direct URLs (LAN,18 * A saved environment can hold several routes: T3 Connect, direct URLs (LAN,
18 * tailnet, public), and SSH. The client connects over the first route in19 * tailnet, other VPNs, public), and SSH. The client connects over the first route in
19 * preference order that answers as the expected environment, and moves back20 * preference order that answers as the expected environment, and moves back
20 * to a better one when it becomes reachable again.21 * to a better one when it becomes reachable again.
21 */22 */
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
25/** An environment has at most one T3 Connect route, so it needs no per-route id. */33/** An environment has at most one T3 Connect route, so it needs no per-route id. */
26export const RELAY_ROUTE_ID = "relay";34export const RELAY_ROUTE_ID = "relay";
...@@ -96,7 +104,11 @@ export function connectionRouteKind(route: ConnectionRoute): ConnectionRouteKind...@@ -96,7 +104,11 @@ export function connectionRouteKind(route: ConnectionRoute): ConnectionRouteKind
96 const hostname = routeHostname(route);104 const hostname = routeHostname(route);
97 if (hostname === null) return "public";105 if (hostname === null) return "public";
98 if (isLocalLoopbackHost(hostname)) return "loopback";106 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";
100 return isPrivateNetworkHost(hostname) ? "lan" : "public";112 return isPrivateNetworkHost(hostname) ? "lan" : "public";
101 }113 }
102 }114 }
...@@ -106,6 +118,7 @@ const ROUTE_KIND_RANK: Record<ConnectionRouteKind, number> = {...@@ -106,6 +118,7 @@ const ROUTE_KIND_RANK: Record<ConnectionRouteKind, number> = {
106 loopback: 0,118 loopback: 0,
107 lan: 1,119 lan: 1,
108 tailnet: 2,120 tailnet: 2,
121 vpn: 2,
109 public: 3,122 public: 3,
110 ssh: 4,123 ssh: 4,
111 relay: 5,124 relay: 5,
...@@ -166,7 +179,7 @@ function routeAddressKey(route: ConnectionRoute): string | null {...@@ -166,7 +179,7 @@ function routeAddressKey(route: ConnectionRoute): string | null {
166 }179 }
167}180}
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. */
170export function connectionRouteLabel(route: ConnectionRoute): string {183export function connectionRouteLabel(route: ConnectionRoute): string {
171 switch (connectionRouteKind(route)) {184 switch (connectionRouteKind(route)) {
172 case "relay":185 case "relay":
...@@ -177,6 +190,8 @@ export function connectionRouteLabel(route: ConnectionRoute): string {...@@ -177,6 +190,8 @@ export function connectionRouteLabel(route: ConnectionRoute): string {
177 return "LAN";190 return "LAN";
178 case "tailnet":191 case "tailnet":
179 return "Tailscale";192 return "Tailscale";
193 case "vpn":
194 return "VPN";
180 case "ssh": {195 case "ssh": {
181 const profile = Option.getOrNull(route.profile);196 const profile = Option.getOrNull(route.profile);
182 return profile?._tag === "SshConnectionProfile"197 return profile?._tag === "SshConnectionProfile"
...@@ -212,13 +227,16 @@ export function hasRelayRoute(...@@ -212,13 +227,16 @@ export function hasRelayRoute(
212 * in use: the paired token for a bearer route, the T3 Connect credential for227 * in use: the paired token for a bearer route, the T3 Connect credential for
213 * relay. A learned route the server still reports keeps its place, so the228 * relay. A learned route the server still reports keeps its place, so the
214 * user's order holds; one it no longer reports is dropped, so a changed LAN229 * 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, and230 * address replaces the old one. Routes the user saved are never removed or
216 * an address already saved is not learned twice.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.
217 */235 */
218export function mergeLearnedRoutes(input: {236export function mergeLearnedRoutes(input: {
219 readonly entry: ConnectionCatalogEntry;237 readonly entry: ConnectionCatalogEntry;
220 readonly activeRoute: ConnectionRoute;238 readonly activeRoute: ConnectionRoute;
221 readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>;239 readonly reported: ReadonlyArray<ReportedEndpoint>;
222 /** Plain HTTP routes are unusable from an HTTPS page (mixed content). */240 /** Plain HTTP routes are unusable from an HTTPS page (mixed content). */
223 readonly allowInsecure: boolean;241 readonly allowInsecure: boolean;
224}): ReadonlyArray<ConnectionRoute> | null {242}): ReadonlyArray<ConnectionRoute> | null {
...@@ -245,6 +263,7 @@ export function mergeLearnedRoutes(input: {...@@ -245,6 +263,7 @@ export function mergeLearnedRoutes(input: {
245263
246 // Usable reported addresses, by origin.264 // Usable reported addresses, by origin.
247 const reported = new Map<string, URL>();265 const reported = new Map<string, URL>();
266 const tailscaleOrigins = new Set<string>();
248 for (const endpoint of input.reported) {267 for (const endpoint of input.reported) {
249 let url: URL;268 let url: URL;
250 try {269 try {
...@@ -257,6 +276,7 @@ export function mergeLearnedRoutes(input: {...@@ -257,6 +276,7 @@ export function mergeLearnedRoutes(input: {
257 // A loopback address names whichever device opens it, never the server.276 // A loopback address names whichever device opens it, never the server.
258 if (isLocalLoopbackHost(url.hostname)) continue;277 if (isLocalLoopbackHost(url.hostname)) continue;
259 reported.set(url.origin, url);278 reported.set(url.origin, url);
279 if (endpoint.kind === "tailnet") tailscaleOrigins.add(url.origin);
260 }280 }
261 const normalized = (url: string) => url.replace(/\/+$/, "");281 const normalized = (url: string) => url.replace(/\/+$/, "");
262 const known = new Set(282 const known = new Set(
...@@ -265,12 +285,18 @@ export function mergeLearnedRoutes(input: {...@@ -265,12 +285,18 @@ export function mergeLearnedRoutes(input: {
265 return url === null || isLearned(route) ? [] : [normalized(url)];285 return url === null || isLearned(route) ? [] : [normalized(url)];
266 }),286 }),
267 );287 );
268 const kept = saved.filter((route) => {288 const kept = saved.flatMap((route): ReadonlyArray<ConnectionRoute> => {
269 if (!isLearned(route)) return true;
270 const url = routeHttpBaseUrl(route);289 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 [];
272 known.add(normalized(url));298 known.add(normalized(url));
273 return true;299 return [withNetwork(route, tailscaleOrigins.has(normalized(url)))];
274 });300 });
275 let next: ReadonlyArray<ConnectionRoute> = kept;301 let next: ReadonlyArray<ConnectionRoute> = kept;
276 for (const url of reported.values()) {302 for (const url of reported.values()) {
...@@ -296,6 +322,7 @@ export function mergeLearnedRoutes(input: {...@@ -296,6 +322,7 @@ export function mergeLearnedRoutes(input: {
296 wsBaseUrl: `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}/`,322 wsBaseUrl: `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}/`,
297 learned: true,323 learned: true,
298 ...(authorization === undefined ? {} : { authorization }),324 ...(authorization === undefined ? {} : { authorization }),
325 ...(tailscaleOrigins.has(url.origin) ? { network: "tailscale" as const } : {}),
299 }),326 }),
300 ),327 ),
301 });328 });
...@@ -303,11 +330,42 @@ export function mergeLearnedRoutes(input: {...@@ -303,11 +330,42 @@ export function mergeLearnedRoutes(input: {
303 // Compare addresses too: a scheme or port change keeps no id stable.330 // Compare addresses too: a scheme or port change keeps no id stable.
304 const signature = (routes: ReadonlyArray<ConnectionRoute>) =>331 const signature = (routes: ReadonlyArray<ConnectionRoute>) =>
305 routes332 routes
306 .map((route) => `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""}`)333 .map(
334 (route) =>
335 `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""} ${connectionRouteKind(route)}`,
336 )
307 .join("\n");337 .join("\n");
308 return signature(saved) === signature(next) ? null : next;338 return signature(saved) === signature(next) ? null : next;
309}339}
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
311/**369/**
312 * A learned bearer route borrows the credential of the route it was learned370 * A learned bearer route borrows the credential of the route it was learned
313 * from, so its id points back at that credential's owner.371 * 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";...@@ -30,7 +30,12 @@ import * as RpcSession from "../rpc/session.ts";
30import { safeErrorLogAttributes } from "../errors/safeLog.ts";30import { safeErrorLogAttributes } from "../errors/safeLog.ts";
31import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";31import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
32import * as ConnectionWakeups from "./wakeups.ts";32import * 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
35const RETRY_BASE_DELAY_MS = 1_000;40const RETRY_BASE_DELAY_MS = 1_000;
36const RETRY_MAX_DELAY_MS = 300_000;41const RETRY_MAX_DELAY_MS = 300_000;
...@@ -120,7 +125,7 @@ export interface EnvironmentSupervisorOptions {...@@ -120,7 +125,7 @@ export interface EnvironmentSupervisorOptions {
120 */125 */
121 readonly learnRoutes?: (input: {126 readonly learnRoutes?: (input: {
122 readonly activeRoute: ConnectionRoute;127 readonly activeRoute: ConnectionRoute;
123 readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>;128 readonly reported: ReadonlyArray<ReportedEndpoint>;
124 }) => Effect.Effect<Option.Option<ConnectionCatalogEntry>>;129 }) => Effect.Effect<Option.Option<ConnectionCatalogEntry>>;
125}130}
126131
packages/contracts/src/server.ts+4
...@@ -632,6 +632,10 @@ export function environmentThemeFileHasColors(file: EnvironmentThemeFile): boole...@@ -632,6 +632,10 @@ export function environmentThemeFileHasColors(file: EnvironmentThemeFile): boole
632 );632 );
633}633}
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 */
635export const ServerDirectEndpointKind = Schema.Literals(["lan", "tailnet"]);639export const ServerDirectEndpointKind = Schema.Literals(["lan", "tailnet"]);
636export type ServerDirectEndpointKind = typeof ServerDirectEndpointKind.Type;640export type ServerDirectEndpointKind = typeof ServerDirectEndpointKind.Type;
637641
packages/shared/src/hostClassification.ts+12-4
...@@ -82,18 +82,26 @@ export const isLocalLoopbackHost = (host: string): boolean => {...@@ -82,18 +82,26 @@ export const isLocalLoopbackHost = (host: string): boolean => {
82};82};
8383
84/**84/**
85 * A Tailscale address: a MagicDNS name, the 100.64.0.0/10 range Tailscale85 * A name or address only Tailscale uses: a MagicDNS name or its IPv6 range
86 * assigns, or its IPv6 range fd7a:115c:a1e0::/48.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.
87 */88 */
88export const isTailnetHost = (host: string): boolean => {89export const isTailnetHost = (host: string): boolean => {
89 const normalized = normalizeHostname(host);90 const normalized = normalizeHostname(host);
90 if (normalized.endsWith(".ts.net")) return true;91 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;
93 const ipv6 = parseIpv6Address(normalized);92 const ipv6 = parseIpv6Address(normalized);
94 return ipv6 !== null && ipv6PrefixMatches(ipv6, [0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 0], 48);93 return ipv6 !== null && ipv6PrefixMatches(ipv6, [0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 0], 48);
95};94};
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
97export const isPrivateNetworkHost = (host: string): boolean => {105export const isPrivateNetworkHost = (host: string): boolean => {
98 const normalized = normalizeHostname(host);106 const normalized = normalizeHostname(host);
99 if (107 if (