mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
chore(ssh): warn about broken SSH on certain versions
This commit is contained in:
+69
-6
@@ -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,10 +146,12 @@ 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 (
|
||||
<>
|
||||
<BrowserSSHCompatibilityBanner warning={compatibilityWarning} />
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-black">
|
||||
<Card className="w-screen" variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
@@ -135,14 +166,46 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user