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 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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user