From 990e1e9ff54d5d6975336c5df358007019a04ab6 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Wed, 1 Jul 2026 11:23:35 -0400 Subject: [PATCH] chore(ssh): warn about broken SSH on certain versions --- app/routes/ssh/page.tsx | 103 ++++++++++++++++++++++++++++++++-------- compose.yaml | 2 +- docs/features/ssh.md | 15 +++++- 3 files changed, 98 insertions(+), 22 deletions(-) diff --git a/app/routes/ssh/page.tsx b/app/routes/ssh/page.tsx index 1332f8c..1f9e9b8 100644 --- a/app/routes/ssh/page.tsx +++ b/app/routes/ssh/page.tsx @@ -5,7 +5,13 @@ import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react import Button from "~/components/button"; import Card from "~/components/card"; 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 type { Route } from "./+types/page"; @@ -26,7 +32,9 @@ export const shouldRevalidate: ShouldRevalidateFunction = () => { export async function loader({ request, params, context, url }: Route.LoaderArgs) { const agents = context.get(agentsContext); const config = context.get(appConfigContext); + const headscale = context.get(headscaleContext); const getRequestApi = context.get(requestApiContext); + const compatibilityWarning = getBrowserSSHCompatibilityWarning(headscale.version); const origin = url.origin; 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) { - return { hostname, username, offline: true, node: undefined }; + return { hostname, username, offline: true, node: undefined, compatibilityWarning }; } 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 @@ -98,9 +112,24 @@ export async function loader({ request, params, context, url }: Route.LoaderArgs preAuthKey: preAuthKey.key, 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) { const hex = crypto.randomUUID().replace(/-/g, "").slice(0, 8); return `ssh-${hex}-${username}`; @@ -117,32 +146,66 @@ export const links: Route.LinksFunction = () => [ ]; export default function Page({ loaderData }: Route.ComponentProps) { - const { hostname, username, offline, node } = loaderData; + const { hostname, username, offline, node, compatibilityWarning } = loaderData; if (offline) { return ( -
- -
- Node Offline - -
- - {hostname} is not currently connected to the Tailnet. - - -
-
+ <> + +
+ +
+ Node Offline + +
+ + {hostname} is not currently connected to the Tailnet. + + +
+
+ ); } if (!username || !node) { - return ; + return ( + <> + + + + ); } - return ; + return ( + <> + + + + ); +} + +function BrowserSSHCompatibilityBanner({ + warning, +}: { + warning: { version: string } | null | undefined; +}) { + if (!warning) return null; + + return ( +
+ + Headscale 0.29 beta releases through 0.29.1 reject Tailscale's browser/WASM{" "} + /ts2021 WebSocket request with 405 Method Not Allowed. Upgrade + Headscale to 0.29.2 or newer, or use Headscale 0.28.x. + +
+ ); } function SSHConsole({ diff --git a/compose.yaml b/compose.yaml index f1ce03b..b3be156 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,7 +24,7 @@ services: - "./test/caddy/config:/config" - "./test/caddy/certs:/certs" headscale: - image: "headscale/headscale:0.28.0" + image: "headscale/headscale:0.29.0" container_name: "headscale" labels: me.tale.headplane.target: headscale diff --git a/docs/features/ssh.md b/docs/features/ssh.md index 1146896..4341393 100644 --- a/docs/features/ssh.md +++ b/docs/features/ssh.md @@ -21,11 +21,20 @@ joins the tailnet for the duration of the SSH session. ## 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`). - Users must be logged-in via **OIDC** (API key logins cannot use browser SSH). - 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 :::tip @@ -172,6 +181,10 @@ to Headscale, then retry. ### 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 non-standard port, `server_url` must include it (e.g. `https://hs.example.com:8443`). The embedded DERP server derives its