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 { 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`; export const shouldRevalidate: ShouldRevalidateFunction = () => { return false; }; export async function loader({ request, params, context }: Route.LoaderArgs) { const origin = new URL(request.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 (context.agents == null) { throw data(sshErrors.agent_required, 400); } const principal = await context.auth.require(request); if (principal.kind === "api_key") { throw data(sshErrors.oidc_required, 403); } const apiKey = context.auth.getHeadscaleApiKey(principal); const api = context.hsApi.getRuntimeClient(apiKey); const hostname = params.id; const username = new URL(request.url).searchParams.get("user") || undefined; const nodes = await api.getNodes(); 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 }; } if (!username) { return { hostname, username: undefined, offline: false, node: undefined }; } // The user must exist within Headscale to generate a pre-auth key const users = await api.getUsers(); const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email); if (!hsUser) { throw data(sshErrors.user_not_linked, 404); } const preAuthKey = await api.createPreAuthKey( hsUser.id, true, false, new Date(Date.now() + 60 * 1000), // 1 minute expiry null, ); const controlURL = context.config.headscale.public_url ?? context.config.headscale.url; return { hostname, username, offline: false, node: { ipAddress: node.ipAddresses[0], controlURL, preAuthKey: preAuthKey.key, ephemeralHostname: generateHostname(username), }, }; } 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 } = loaderData; if (offline) { return (
{hostname} is not currently connected to the Tailnet.
{status}