Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions packages/client-runtime/src/rpc/http.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}),
);
});
33 changes: 32 additions & 1 deletion packages/client-runtime/src/rpc/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}),
);
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
64 changes: 64 additions & 0 deletions packages/shared/src/privateNetworkHost.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
105 changes: 105 additions & 0 deletions packages/shared/src/privateNetworkHost.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}