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
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* The overlay is the only thing a blocked visitor sees, so what it claims
* matters. On a public origin it used to spin forever and tell the visitor to
* start or install a coordinator that was already running.
*/
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ConnectionOverlay } from "./ConnectionOverlay.tsx";

function renderAt(hostname: string, host = hostname): string {
const original = globalThis.window;
// The component reads location to decide, and prints the host it was served
// from, so both fields have to be present.
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { ...(original ?? {}), location: { hostname, host } },
});
try {
return renderToStaticMarkup(<ConnectionOverlay />);
} finally {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: original,
});
}
}

describe("ConnectionOverlay", () => {
test("on a public origin it names the real obstacle and the real remedy", () => {
const html = renderAt("dashboard.local.han.guru");

expect(html).toContain("Open the dashboard from your machine");
expect(html).toContain("han browse");
// The host is quoted back so the visitor can see why this page is affected.
expect(html).toContain("dashboard.local.han.guru");
});

test("on a public origin it does not blame the coordinator", () => {
const html = renderAt("dashboard.local.han.guru");

expect(html).not.toContain("han coordinator start");
expect(html).not.toContain("Connecting to Han Coordinator");
// Waiting cannot fix a permission, so nothing should suggest it will.
expect(html).not.toContain("Checking every 3 seconds");
});

test("on loopback it keeps the coordinator instructions, which are correct there", () => {
const html = renderAt("127.0.0.1", "127.0.0.1:41956");

expect(html).toContain("Connecting to Han Coordinator");
expect(html).toContain("han coordinator start");
expect(html).not.toContain("Open the dashboard from your machine");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import type React from "react";
import { useEffect, useState } from "react";
import { isBlockedByLocalNetworkAccess } from "../../helpers/localNetworkAccess.ts";
import { colors, fontSizes, fonts, radii, spacing } from "../../theme.ts";
import { Box, Heading, Spinner, Text, VStack } from "../atoms/index.ts";

Expand Down Expand Up @@ -104,15 +105,91 @@ function Step({
);
}

/**
* Shown when the browser, not the coordinator, is the obstacle.
*
* No spinner and no "checking every 3 seconds": this will not resolve by
* waiting, and a spinner would imply otherwise. `han browse` serves this same
* dashboard from 127.0.0.1, which is not gated.
*/
function LocalNetworkBlockedPanel(): React.ReactElement {
return (
<Box style={overlayStyle}>
<Box style={contentBoxStyle}>
<VStack gap="xl" align="center">
<VStack gap="sm" align="center">
<Heading size="md">Open the dashboard from your machine</Heading>
<Text
color="muted"
size="sm"
style={{ textAlign: "center" as const }}
>
Your coordinator may well be running. This page is served from{" "}
{window.location.host}, and browsers block a page on a public
address from reaching one on your own machine, so it cannot read
the coordinator from here.
</Text>
</VStack>

<Box
style={{
width: "100%",
height: 1,
backgroundColor: colors.border.subtle,
}}
/>

<VStack gap="lg" style={{ width: "100%" }}>
<Step number={1} title="Run this instead:" code="han browse">
<Text color="muted" size="xs">
It serves this dashboard from 127.0.0.1 and opens it for you.
Same dashboard, an address the browser allows.
</Text>
</Step>

<Step number={2} title="Need to install Han?">
<VStack gap="xs">
<Box style={codeBlockStyle}>
<Text size="sm" style={{ fontFamily: fonts.mono }}>
curl -fsSL https://han.guru/install.sh | bash
</Text>
</Box>
<Text color="muted" size="xs">
or
</Text>
<Box style={codeBlockStyle}>
<Text size="sm" style={{ fontFamily: fonts.mono }}>
brew install thebushidocollective/tap/han
</Text>
</Box>
</VStack>
</Step>
</VStack>
</VStack>
</Box>
</Box>
);
}

export function ConnectionOverlay(): React.ReactElement {
const [pulseVisible, setPulseVisible] = useState(true);
// Read once: neither the page origin nor the configured endpoint changes
// without a reload, and this decides whether waiting is even meaningful.
const [blocked] = useState(() => isBlockedByLocalNetworkAccess());

useEffect(() => {
if (blocked) {
return;
}
const interval = setInterval(() => {
setPulseVisible((v) => !v);
}, 1500);
return () => clearInterval(interval);
}, []);
}, [blocked]);

if (blocked) {
return <LocalNetworkBlockedPanel />;
}

return (
<Box style={overlayStyle}>
Expand Down
79 changes: 79 additions & 0 deletions packages/browse-client/src/helpers/localNetworkAccess.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import {
coordinatorIsLoopback,
isBlockedByLocalNetworkAccess,
isLoopbackOrigin,
} from "./localNetworkAccess.ts";

describe("isLoopbackOrigin", () => {
test("recognises the loopback names a page can be served from", () => {
expect(isLoopbackOrigin("localhost")).toBe(true);
expect(isLoopbackOrigin("127.0.0.1")).toBe(true);
// The whole 127/8 block is loopback, not only .0.1.
expect(isLoopbackOrigin("127.0.0.53")).toBe(true);
expect(isLoopbackOrigin("::1")).toBe(true);
});

test("treats the hosted dashboard and lookalikes as public", () => {
expect(isLoopbackOrigin("dashboard.local.han.guru")).toBe(false);
expect(isLoopbackOrigin("localhost.evil.com")).toBe(false);
expect(isLoopbackOrigin("127.0.0.1.evil.com")).toBe(false);
expect(isLoopbackOrigin("192.168.1.10")).toBe(false);
});
});

describe("coordinatorIsLoopback", () => {
test("counts the coordinator's public DNS name, which resolves to 127.0.0.1", () => {
expect(
coordinatorIsLoopback("https://coordinator.local.han.guru:41957/graphql"),
).toBe(true);
expect(coordinatorIsLoopback("http://127.0.0.1:41957/graphql")).toBe(true);
});

test("a routable coordinator is not loopback", () => {
expect(coordinatorIsLoopback("https://coord.example.com/graphql")).toBe(
false,
);
expect(coordinatorIsLoopback("https://10.0.0.5:41957/graphql")).toBe(false);
});

test("an unparseable endpoint is not assumed to be loopback", () => {
expect(coordinatorIsLoopback("not a url")).toBe(false);
});
});

describe("isBlockedByLocalNetworkAccess", () => {
const coordinatorEndpoint =
"https://coordinator.local.han.guru:41957/graphql";

test("the hosted dashboard reaching a loopback coordinator is blocked", () => {
// This is the case that showed a spinner and coordinator instructions
// forever, however healthy the coordinator was.
expect(
isBlockedByLocalNetworkAccess({
pageHostname: "dashboard.local.han.guru",
coordinatorEndpoint,
}),
).toBe(true);
});

test("a page already on loopback is not blocked", () => {
expect(
isBlockedByLocalNetworkAccess({
pageHostname: "127.0.0.1",
coordinatorEndpoint,
}),
).toBe(false);
});

test("a public page reaching a routable coordinator is not blocked", () => {
// Nothing about that crosses into local address space, so a failure there
// really does mean the coordinator is unreachable.
expect(
isBlockedByLocalNetworkAccess({
pageHostname: "dashboard.local.han.guru",
coordinatorEndpoint: "https://coord.example.com/graphql",
}),
).toBe(false);
});
});
73 changes: 73 additions & 0 deletions packages/browse-client/src/helpers/localNetworkAccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Local Network Access detection.
*
* The coordinator listens on loopback. Chrome 151 gates a page served from a
* public origin from reaching a loopback address behind the
* `local-network-access` permission, and the page cannot obtain that permission
* itself: a fetch carrying `targetAddressSpace: "loopback"` still fails, with or
* without a user gesture, and the permission stays at "prompt".
*
* So a public-origin page never connects, however healthy the coordinator is.
* Telling the visitor to start or install the coordinator in that situation is
* wrong, and it is what the connection overlay used to do forever.
*/

import { getGraphQLEndpoints } from "../config/urls.ts";

/**
* Hostnames that resolve to this machine.
*
* `coordinator.local.han.guru` is a public DNS name pointing at 127.0.0.1, so it
* belongs here: it is a loopback target even though it does not look like one.
*/
const LOOPBACK_HOSTNAMES = new Set([
"localhost",
"127.0.0.1",
"::1",
"[::1]",
"coordinator.local.han.guru",
]);

function isLoopbackHostname(hostname: string): boolean {
if (LOOPBACK_HOSTNAMES.has(hostname)) {
return true;
}
// The whole 127.0.0.0/8 block is loopback, not just 127.0.0.1.
return /^127(?:\.\d{1,3}){3}$/.test(hostname);
}

/** Whether the page itself is served from this machine. */
export function isLoopbackOrigin(hostname?: string): boolean {
const host =
hostname ?? (typeof window === "undefined" ? "" : window.location.hostname);
return host !== "" && isLoopbackHostname(host);
}

/** Whether the configured coordinator is a loopback target. */
export function coordinatorIsLoopback(endpoint?: string): boolean {
const url = endpoint ?? getGraphQLEndpoints().http;
try {
return isLoopbackHostname(new URL(url).hostname);
} catch {
return false;
}
}

/**
* Whether this page is blocked from reaching the coordinator by the browser
* rather than by the coordinator being absent.
*
* True only when the page is public *and* the coordinator is loopback. A page
* already on loopback is unrestricted, and a coordinator on a routable address
* is not gated, so in both of those cases a failure really does mean the
* coordinator is unreachable and the normal advice applies.
*/
export function isBlockedByLocalNetworkAccess(options?: {
pageHostname?: string;
coordinatorEndpoint?: string;
}): boolean {
return (
!isLoopbackOrigin(options?.pageHostname) &&
coordinatorIsLoopback(options?.coordinatorEndpoint)
);
}
Loading