import { Loader2, WifiOff } from "lucide-react"; import { useEffect, useState } from "react"; import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react-router"; import Button from "~/components/button"; import Card from "~/components/card"; import Code from "~/components/code"; 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"; import { isSSHError, SSHErrorBoundary, sshErrors } from "./errors"; import Ghostty from "./ghostty.client"; import UserPrompt from "./user-prompt"; import type { HeadplaneSSH } from "./wasm.client"; import { loadHeadplaneWASM } from "./wasm.client"; const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`; const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`; const SSH_PREAUTH_KEY_TTL_MS = 10 * 60 * 1000; export const shouldRevalidate: ShouldRevalidateFunction = () => { return false; }; 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]; const missing: string[] = []; for (const file of assets) { const res = await fetch(`${origin}${file}`, { method: "HEAD" }); if (!res.ok) { missing.push(file); } } if (missing.length > 0) { throw data(sshErrors.wasm_missing, 405); } if (agents.state !== "enabled") { throw data(sshErrors.agent_required, 400); } const { principal, api } = await getRequestApi(request); if (principal.kind === "api_key") { throw data(sshErrors.oidc_required, 403); } const hostname = params.id; const username = url.searchParams.get("user") || undefined; const nodes = await api.nodes.list(); const node = nodes.find((n) => n.givenName === hostname); if (!node) { throw data(sshErrors.node_not_found(hostname), 404); } if (!node.online) { return { hostname, username, offline: true, node: undefined, compatibilityWarning }; } if (!username) { return { hostname, username: undefined, offline: false, node: undefined, compatibilityWarning, }; } // The user must exist within Headscale to generate a pre-auth key const users = await api.users.list(); const hsUser = principal.user.headscaleUserId ? users.find((u) => u.id === principal.user.headscaleUserId) : findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email); if (!hsUser) { throw data(sshErrors.user_not_linked, 404); } const preAuthKey = await api.preAuthKeys.create({ user: hsUser.id, ephemeral: true, reusable: false, expiration: new Date(Date.now() + SSH_PREAUTH_KEY_TTL_MS), aclTags: null, }); const controlURL = config.headscale.public_url ?? config.headscale.url; return { hostname, username, offline: false, node: { ipAddress: node.ipAddresses[0], controlURL, 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}`; } export const links: Route.LinksFunction = () => [ { rel: "preload", href: WASM_MODULE_URL, as: "fetch", type: "application/wasm", crossOrigin: "anonymous", }, ]; export default function Page({ loaderData }: Route.ComponentProps) { const { hostname, username, offline, node, compatibilityWarning } = loaderData; if (offline) { return ( <>
Node Offline
{hostname} is not currently connected to the Tailnet.
); } if (!username || !node) { 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({ hostname, username, node, }: { hostname: string; username: string; node: { ipAddress: string; controlURL: string; preAuthKey: string; ephemeralHostname: string }; }) { const [ssh, setSsh] = useState(null); const [connected, setConnected] = useState(false); const [status, setStatus] = useState("Starting tunnel…"); useEffect(() => { let cancelled = false; console.log("[ssh] Loading WASM factory"); loadHeadplaneWASM().then((create) => { console.log("[ssh] Factory loaded, creating IPN", create); if (cancelled) { return; } setStatus("Joining Tailnet…"); const instance = create({ controlURL: node.controlURL, preAuthKey: node.preAuthKey, hostname: node.ephemeralHostname, onReady: () => { console.log("[ssh] IPN ready (Running)"); if (!cancelled) { setStatus(`Connecting to ${hostname}…`); setSsh(instance); } }, onError: (msg) => { console.error("[ssh] IPN error:", msg); if (!cancelled) { setStatus(`Failed to join Tailnet: ${msg}`); } }, }); console.log("[ssh] IPN instance created", instance); }); return () => { cancelled = true; }; }, [node]); return (
{!connected && (

{status}

)} {ssh && ( setConnected(true)} /> )}
); } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { const routeError = isRouteErrorResponse(error) ? error.data : null; if (routeError == null || !isSSHError(routeError)) { // Pass through further down the tree to the global error boundary throw error; } return (
); }