chore(ssh): warn about broken SSH on certain versions

This commit is contained in:
Aarnav Tale
2026-07-01 11:23:35 -04:00
parent 18263e4878
commit 990e1e9ff5
3 changed files with 98 additions and 22 deletions
+69 -6
View File
@@ -5,7 +5,13 @@ import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react
import Button from "~/components/button"; import Button from "~/components/button";
import Card from "~/components/card"; import Card from "~/components/card";
import Code from "~/components/code"; import Code from "~/components/code";
import { agentsContext, appConfigContext, requestApiContext } from "~/server/context"; import StatusBanner from "~/components/status-banner";
import {
agentsContext,
appConfigContext,
headscaleContext,
requestApiContext,
} from "~/server/context";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import type { Route } from "./+types/page"; import type { Route } from "./+types/page";
@@ -26,7 +32,9 @@ export const shouldRevalidate: ShouldRevalidateFunction = () => {
export async function loader({ request, params, context, url }: Route.LoaderArgs) { export async function loader({ request, params, context, url }: Route.LoaderArgs) {
const agents = context.get(agentsContext); const agents = context.get(agentsContext);
const config = context.get(appConfigContext); const config = context.get(appConfigContext);
const headscale = context.get(headscaleContext);
const getRequestApi = context.get(requestApiContext); const getRequestApi = context.get(requestApiContext);
const compatibilityWarning = getBrowserSSHCompatibilityWarning(headscale.version);
const origin = url.origin; const origin = url.origin;
const assets = [WASM_HELPER_URL, WASM_MODULE_URL]; const assets = [WASM_HELPER_URL, WASM_MODULE_URL];
@@ -62,11 +70,17 @@ export async function loader({ request, params, context, url }: Route.LoaderArgs
} }
if (!node.online) { if (!node.online) {
return { hostname, username, offline: true, node: undefined }; return { hostname, username, offline: true, node: undefined, compatibilityWarning };
} }
if (!username) { if (!username) {
return { hostname, username: undefined, offline: false, node: undefined }; return {
hostname,
username: undefined,
offline: false,
node: undefined,
compatibilityWarning,
};
} }
// The user must exist within Headscale to generate a pre-auth key // The user must exist within Headscale to generate a pre-auth key
@@ -98,9 +112,24 @@ export async function loader({ request, params, context, url }: Route.LoaderArgs
preAuthKey: preAuthKey.key, preAuthKey: preAuthKey.key,
ephemeralHostname: generateHostname(username), ephemeralHostname: generateHostname(username),
}, },
compatibilityWarning,
}; };
} }
function getBrowserSSHCompatibilityWarning(version: {
unknown: boolean;
major: number;
minor: number;
patch: number;
raw: string;
}) {
if (version.unknown) return null;
if (version.major === 0 && version.minor === 29 && version.patch < 2) {
return { version: version.raw };
}
return null;
}
function generateHostname(username: string) { function generateHostname(username: string) {
const hex = crypto.randomUUID().replace(/-/g, "").slice(0, 8); const hex = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
return `ssh-${hex}-${username}`; return `ssh-${hex}-${username}`;
@@ -117,10 +146,12 @@ export const links: Route.LinksFunction = () => [
]; ];
export default function Page({ loaderData }: Route.ComponentProps) { export default function Page({ loaderData }: Route.ComponentProps) {
const { hostname, username, offline, node } = loaderData; const { hostname, username, offline, node, compatibilityWarning } = loaderData;
if (offline) { if (offline) {
return ( return (
<>
<BrowserSSHCompatibilityBanner warning={compatibilityWarning} />
<div className="flex h-screen w-screen items-center justify-center bg-black"> <div className="flex h-screen w-screen items-center justify-center bg-black">
<Card className="w-screen" variant="flat"> <Card className="w-screen" variant="flat">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
@@ -135,14 +166,46 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</Button> </Button>
</Card> </Card>
</div> </div>
</>
); );
} }
if (!username || !node) { if (!username || !node) {
return <UserPrompt hostname={hostname} />; return (
<>
<BrowserSSHCompatibilityBanner warning={compatibilityWarning} />
<UserPrompt hostname={hostname} />
</>
);
} }
return <SSHConsole hostname={hostname} username={username} node={node} />; return (
<>
<BrowserSSHCompatibilityBanner warning={compatibilityWarning} />
<SSHConsole hostname={hostname} username={username} node={node} />
</>
);
}
function BrowserSSHCompatibilityBanner({
warning,
}: {
warning: { version: string } | null | undefined;
}) {
if (!warning) return null;
return (
<div className="fixed inset-x-4 top-4 z-[60] mx-auto max-w-2xl">
<StatusBanner
variant="warning"
title={`Browser SSH is broken on Headscale ${warning.version}`}
>
Headscale 0.29 beta releases through 0.29.1 reject Tailscale's browser/WASM{" "}
<Code>/ts2021</Code> WebSocket request with <Code>405 Method Not Allowed</Code>. Upgrade
Headscale to 0.29.2 or newer, or use Headscale 0.28.x.
</StatusBanner>
</div>
);
} }
function SSHConsole({ function SSHConsole({
+1 -1
View File
@@ -24,7 +24,7 @@ services:
- "./test/caddy/config:/config" - "./test/caddy/config:/config"
- "./test/caddy/certs:/certs" - "./test/caddy/certs:/certs"
headscale: headscale:
image: "headscale/headscale:0.28.0" image: "headscale/headscale:0.29.0"
container_name: "headscale" container_name: "headscale"
labels: labels:
me.tale.headplane.target: headscale me.tale.headplane.target: headscale
+14 -1
View File
@@ -21,11 +21,20 @@ joins the tailnet for the duration of the SSH session.
## Prerequisites ## Prerequisites
- **Headscale 0.28 or newer** is required. - **Headscale 0.28 or newer** is required. Browser SSH is broken in the
Headscale 0.29 beta releases through 0.29.1; use Headscale 0.28.x or 0.29.2
and newer.
- Target nodes must have **Tailscale SSH** enabled (`tailscale up --ssh`). - Target nodes must have **Tailscale SSH** enabled (`tailscale up --ssh`).
- Users must be logged-in via **OIDC** (API key logins cannot use browser SSH). - Users must be logged-in via **OIDC** (API key logins cannot use browser SSH).
- The **Headplane Agent** must be [enabled and configured](/features/agent). - The **Headplane Agent** must be [enabled and configured](/features/agent).
:::warning Headscale 0.29.0 beta through 0.29.1
Browser SSH does not work with Headscale 0.29 beta releases through 0.29.1 due
to a `/ts2021` WebSocket routing regression. These versions reject Tailscale's
browser/WASM control-plane WebSocket request with `405 Method Not Allowed`.
Upgrade Headscale to 0.29.2 or newer, or use Headscale 0.28.x.
:::
## How It Works ## How It Works
:::tip :::tip
@@ -172,6 +181,10 @@ to Headscale, then retry.
### Connection fails with EOF or hangs ### Connection fails with EOF or hangs
- **Check your Headscale version.** Browser SSH is broken in Headscale 0.29 beta
releases through 0.29.1 due to a `/ts2021` WebSocket routing regression. If
the browser console shows `405 Method Not Allowed` for `/ts2021`, upgrade to
Headscale 0.29.2 or newer, or use Headscale 0.28.x.
- **Check `server_url` in your Headscale config.** If Headscale runs on a - **Check `server_url` in your Headscale config.** If Headscale runs on a
non-standard port, `server_url` must include it (e.g. non-standard port, `server_url` must include it (e.g.
`https://hs.example.com:8443`). The embedded DERP server derives its `https://hs.example.com:8443`). The embedded DERP server derives its