diff --git a/packages/client-runtime/src/rpc/http.test.ts b/packages/client-runtime/src/rpc/http.test.ts new file mode 100644 index 000000000000..55d246e7625e --- /dev/null +++ b/packages/client-runtime/src/rpc/http.test.ts @@ -0,0 +1,66 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClientError, HttpClientRequest } from "effect/unstable/http"; + +import { executeEnvironmentHttpRequest } from "./http.ts"; + +const transportError = (url: string) => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request: HttpClientRequest.get(url), + }), + }); + +const failingRequest = (url: string) => + executeEnvironmentHttpRequest(url, 10_000, Effect.fail(transportError(url))); + +describe("executeEnvironmentHttpRequest", () => { + it.effect("explains that a local-network address needs the same network", () => + Effect.gen(function* () { + const url = "http://192.168.2.37:3773/.well-known/t3/environment"; + const error = yield* failingRequest(url).pipe(Effect.flip); + + assert.equal(error._tag, "RemoteEnvironmentAuthFetchError"); + // The raw transport detail stays, so the message is still diagnosable. + assert.include(error.message, `Failed to fetch remote environment endpoint ${url}`); + assert.include(error.message, "Transport error"); + // ...and the part a user can act on names the host and the actual cause. + assert.include(error.message, "Nothing answered at 192.168.2.37:3773"); + assert.include(error.message, "same local network"); + assert.include(error.message, "mobile data cannot reach it"); + }), + ); + + it.effect("hints for every private range a desktop can land on", () => + Effect.gen(function* () { + for (const host of ["10.0.0.4:3773", "172.16.5.6:3773", "127.0.0.1:3773", "macbook.local"]) { + const error = yield* failingRequest(`http://${host}/.well-known/t3/environment`).pipe( + Effect.flip, + ); + assert.include(error.message, `Nothing answered at ${host}`); + } + }), + ); + + it.effect("leaves routable hosts with the plain transport message", () => + Effect.gen(function* () { + const url = "https://app.t3.codes/.well-known/t3/environment"; + const error = yield* failingRequest(url).pipe(Effect.flip); + + assert.include(error.message, "Failed to fetch remote environment endpoint"); + assert.notInclude(error.message, "Nothing answered at"); + assert.notInclude(error.message, "mobile data"); + }), + ); + + // `live` because the timeout has to elapse on the real clock. + it.live("does not hint on a timeout, which reports its own reason", () => + Effect.gen(function* () { + const url = "http://192.168.2.37:3773/.well-known/t3/environment"; + const error = yield* executeEnvironmentHttpRequest(url, 1, Effect.never).pipe(Effect.flip); + + assert.equal(error._tag, "RemoteEnvironmentAuthTimeoutError"); + assert.notInclude(error.message, "Nothing answered at"); + }), + ); +}); diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts index 87495918598e..d260c9d82987 100644 --- a/packages/client-runtime/src/rpc/http.ts +++ b/packages/client-runtime/src/rpc/http.ts @@ -9,6 +9,7 @@ import { type EnvironmentScopeRequiredError, } from "@t3tools/contracts"; import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; +import { isPrivateNetworkUrl } from "@t3tools/shared/privateNetworkHost"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -99,6 +100,34 @@ export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => baseUrl: remoteApiBaseUrl(httpBaseUrl), }); +/** + * Extra guidance for a request that never got a response. + * + * A transport error against an RFC1918 address is nearly always the network + * rather than the server: the phone sits on mobile data or another SSID, or it + * kept an address the desktop only had on an earlier network. The raw + * `Transport error` gives no way to tell that apart from a dead backend, so say + * what to check. + */ +const localNetworkTransportHint = (requestUrl: string): string => { + if (!isPrivateNetworkUrl(requestUrl)) { + return ""; + } + + let host: string; + try { + host = new URL(requestUrl).host; + } catch { + return ""; + } + + return ( + ` Nothing answered at ${host}. That address only works from the same local network,` + + ` so check this device is on that network (mobile data cannot reach it) and that the` + + ` address still matches the computer — it changes when the computer joins another network.` + ); +}; + const failRemoteRequest = ( requestUrl: string, cause: unknown, @@ -133,7 +162,9 @@ const failRemoteRequest = ( } return Effect.fail( new RemoteEnvironmentAuthFetchError({ - message: `Failed to fetch remote environment endpoint ${requestUrl} (${String(cause)}).`, + message: + `Failed to fetch remote environment endpoint ${requestUrl} (${String(cause)}).` + + localNetworkTransportHint(requestUrl), cause, }), ); diff --git a/packages/shared/package.json b/packages/shared/package.json index 8cdae3e51605..703cf12637a9 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -51,6 +51,10 @@ "types": "./src/Net.ts", "import": "./src/Net.ts" }, + "./privateNetworkHost": { + "types": "./src/privateNetworkHost.ts", + "import": "./src/privateNetworkHost.ts" + }, "./DrainableWorker": { "types": "./src/DrainableWorker.ts", "import": "./src/DrainableWorker.ts" diff --git a/packages/shared/src/privateNetworkHost.test.ts b/packages/shared/src/privateNetworkHost.test.ts new file mode 100644 index 000000000000..d9b565d68b97 --- /dev/null +++ b/packages/shared/src/privateNetworkHost.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isPrivateNetworkHostname, isPrivateNetworkUrl } from "./privateNetworkHost.ts"; + +describe("isPrivateNetworkHostname", () => { + it.each([ + "127.0.0.1", + "127.1.2.3", + "10.0.0.1", + "172.16.0.1", + "172.31.255.254", + "192.168.1.21", + "192.168.2.37", + "169.254.10.20", + "localhost", + "app.localhost", + "macbook-air.local", + "MacBook-Air.Local", + "::1", + "[::1]", + "fd12:3456::1", + "fe80::1", + ])("treats %s as local-network only", (hostname: string) => { + expect(isPrivateNetworkHostname(hostname)).toBe(true); + }); + + it.each([ + "example.com", + "app.t3.codes", + "8.8.8.8", + "172.15.0.1", + "172.32.0.1", + "192.169.1.1", + "11.0.0.1", + "2606:4700::1111", + "", + " ", + ])("treats %s as routable", (hostname: string) => { + expect(isPrivateNetworkHostname(hostname)).toBe(false); + }); + + it("leaves tailnet CGNAT addresses out, since they route over the tailnet", () => { + // Local-network advice would be wrong for these, so they must not match. + expect(isPrivateNetworkHostname("100.82.16.5")).toBe(false); + expect(isPrivateNetworkHostname("100.90.1.2")).toBe(false); + }); + + it("rejects malformed IPv4 literals rather than guessing", () => { + expect(isPrivateNetworkHostname("192.168.1.999")).toBe(false); + expect(isPrivateNetworkHostname("10.0.0")).toBe(false); + }); +}); + +describe("isPrivateNetworkUrl", () => { + it("reads the hostname out of a full URL", () => { + expect(isPrivateNetworkUrl("http://192.168.2.37:3773/.well-known/t3/environment")).toBe(true); + expect(isPrivateNetworkUrl("https://app.t3.codes/pair")).toBe(false); + }); + + it("returns false for input that is not an absolute URL", () => { + expect(isPrivateNetworkUrl("192.168.2.37:3773")).toBe(false); + expect(isPrivateNetworkUrl("")).toBe(false); + }); +}); diff --git a/packages/shared/src/privateNetworkHost.ts b/packages/shared/src/privateNetworkHost.ts new file mode 100644 index 000000000000..18f4d754bafa --- /dev/null +++ b/packages/shared/src/privateNetworkHost.ts @@ -0,0 +1,105 @@ +/** + * Recognise hosts that only answer from the local machine or its local network. + * + * Pairing a phone with a desktop backend usually means handing the phone an + * RFC1918 address. When the phone is on mobile data, on a different SSID, or + * holding an address the desktop had on an earlier network, the connection dies + * at the transport layer with nothing to distinguish it from a crashed server — + * so callers use this to say "check the network" instead of surfacing a bare + * transport error, and to pick `http` over `https` for scheme-less input. + * + * Deliberately excluded: the `100.64.0.0/10` carrier-grade NAT range that + * Tailscale hands out. Those addresses are private too, but they route over the + * tailnet rather than the local network, so local-network advice would be wrong. + * + * @module privateNetworkHost + */ + +const IPV4_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + +const stripIpv6Brackets = (hostname: string): string => + hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + +const parseIpv4Octets = (hostname: string): readonly number[] | null => { + const match = IPV4_PATTERN.exec(hostname); + if (!match) { + return null; + } + + const octets = match.slice(1, 5).map((octet) => Number.parseInt(octet, 10)); + return octets.every((octet) => octet <= 255) ? octets : null; +}; + +/** `true` for an IPv4 literal in a range that never routes past the local network. */ +const isPrivateIpv4 = (octets: readonly number[]): boolean => { + const [first = 0, second = 0] = octets; + + // 127.0.0.0/8 loopback, 10.0.0.0/8, 192.168.0.0/16, 169.254.0.0/16 link-local. + if (first === 127 || first === 10) return true; + if (first === 192 && second === 168) return true; + if (first === 169 && second === 254) return true; + // 172.16.0.0/12. + return first === 172 && second >= 16 && second <= 31; +}; + +/** `true` for an IPv6 loopback, unique-local (`fc00::/7`), or link-local (`fe80::/10`) literal. */ +const isPrivateIpv6 = (hostname: string): boolean => { + const normalized = hostname.toLowerCase(); + if (normalized === "::1" || normalized === "::") { + return true; + } + if (!normalized.includes(":")) { + return false; + } + + const [firstGroup = ""] = normalized.split(":"); + if (firstGroup.length === 0) { + return false; + } + + const prefix = Number.parseInt(firstGroup.padEnd(4, "0"), 16); + if (Number.isNaN(prefix)) { + return false; + } + + // fc00::/7 unique-local, fe80::/10 link-local. + return (prefix & 0xfe00) === 0xfc00 || (prefix & 0xffc0) === 0xfe80; +}; + +/** + * Whether `hostname` is reachable only from this machine or its local network. + * + * Expects a bare hostname without a port — `new URL(...).hostname` is already in + * that shape, IPv6 brackets included or not. + */ +export function isPrivateNetworkHostname(hostname: string): boolean { + const normalized = stripIpv6Brackets(hostname.trim().toLowerCase()).replace(/\.$/, ""); + if (normalized.length === 0) { + return false; + } + + if (normalized === "localhost" || normalized.endsWith(".localhost")) { + return true; + } + // mDNS names (`macbook.local`) resolve over the local link only. + if (normalized.endsWith(".local")) { + return true; + } + + const octets = parseIpv4Octets(normalized); + return octets ? isPrivateIpv4(octets) : isPrivateIpv6(normalized); +} + +/** + * Whether `url` points at a host reachable only from the local network. + * + * Returns `false` for input that isn't a parseable absolute URL, so callers can + * pass raw user input without guarding first. + */ +export function isPrivateNetworkUrl(url: string): boolean { + try { + return isPrivateNetworkHostname(new URL(url).hostname); + } catch { + return false; + } +}