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