mirror of
https://github.com/tale/headplane.git
synced 2026-08-10 14:06:51 +00:00
feat: rebuild browser ssh from the ground up
This commit is contained in:
+1
-3
@@ -2,7 +2,6 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
|
||||
|
||||
import { ErrorBanner } from "~/components/error-banner";
|
||||
import StatusBanner from "~/components/status-banner";
|
||||
import { pruneEphemeralNodes } from "~/server/db/pruner";
|
||||
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
@@ -30,7 +29,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
const principal = await context.auth.require(request);
|
||||
|
||||
@@ -53,7 +52,6 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||
if (isHealthy) {
|
||||
try {
|
||||
await api.getApiKeys();
|
||||
await pruneEphemeralNodes({ context, request, ...rest });
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
const displayName =
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ export default [
|
||||
route("/logout", "routes/auth/logout.ts"),
|
||||
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
|
||||
route("/oidc/start", "routes/auth/oidc-start.ts"),
|
||||
route("/ssh", "routes/ssh/console.tsx"),
|
||||
route("/ssh/:id", "routes/ssh/page.tsx"),
|
||||
|
||||
// All the main logged-in routes
|
||||
layout("layout/app.tsx", [
|
||||
|
||||
@@ -50,8 +50,8 @@ export default function MachineRow({
|
||||
}, [magic, node.ipAddresses]);
|
||||
|
||||
return (
|
||||
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={node.id}>
|
||||
<td className="py-2 pl-0.5 focus-within:ring-3">
|
||||
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={node.id}>
|
||||
<td className="py-2 pl-2 focus-within:ring-3">
|
||||
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
|
||||
<p
|
||||
className={cn(
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function MachineMenu({
|
||||
// in a new WINDOW since href can only
|
||||
// do a new TAB.
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||
`${__PREFIX__}/ssh/${node.givenName}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer,width=800,height=600",
|
||||
);
|
||||
@@ -127,7 +127,7 @@ export default function MachineMenu({
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||
`${__PREFIX__}/ssh/${node.givenName}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer,width=800,height=600",
|
||||
);
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { data, type ShouldRevalidateFunction, useSubmit } from "react-router";
|
||||
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
|
||||
|
||||
import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema";
|
||||
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||
import { useLiveData } from "~/utils/live-data";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/console";
|
||||
import UserPrompt from "./user-prompt";
|
||||
import XTerm from "./xterm.client";
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const origin = new URL(request.url).origin;
|
||||
const assets = [`${__PREFIX__}/wasm_exec.js`, `${__PREFIX__}/hp_ssh.wasm`];
|
||||
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("WebSSH is not configured in this build.", 405);
|
||||
}
|
||||
|
||||
if (!context.agents) {
|
||||
throw data("WebSSH is only available with the Headplane agent integration", 400);
|
||||
}
|
||||
|
||||
const principal = await context.auth.require(request);
|
||||
if (principal.kind === "api_key") {
|
||||
throw data("Only OAuth users are allowed to use WebSSH", 403);
|
||||
}
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const users = await api.getUsers();
|
||||
|
||||
// MARK: This assumes that a user has authenticated with Headscale first
|
||||
// Since the only way to enforce permissions via ACLs is to generate a
|
||||
// pre-authkey which REQUIRES a user ID, meaning the user has to have
|
||||
// authenticated with Headscale first.
|
||||
const lookup = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
|
||||
|
||||
if (!lookup) {
|
||||
throw data(`User with subject ${principal.user.subject} not found within Headscale`, 404);
|
||||
}
|
||||
|
||||
const preAuthKey = await api.createPreAuthKey(
|
||||
lookup.id,
|
||||
true, // ephemeral
|
||||
false, // reusable
|
||||
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
|
||||
null, // aclTags
|
||||
);
|
||||
|
||||
// TODO: Enable config to enforce generate_authkeys capability
|
||||
// For now, any user is capable of WebSSH connections
|
||||
// const check = await context.sessions.check(
|
||||
// request,
|
||||
// Capabilities.generate_authkeys,
|
||||
// );
|
||||
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const username = qp.get("username") || undefined;
|
||||
const hostname = qp.get("hostname") || undefined;
|
||||
if (!hostname) {
|
||||
throw data("Missing required parameter: hostname", 400);
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
return {
|
||||
ipnDetails: undefined,
|
||||
sshDetails: {
|
||||
username,
|
||||
hostname,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// We're making a request to <url>/key?v=116 to check the CORS headers
|
||||
const u = context.config.headscale.public_url ?? context.config.headscale.url;
|
||||
// const res = await fetch(`${u}/key?v=116`, {
|
||||
// method: 'GET',
|
||||
// });
|
||||
|
||||
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
|
||||
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
|
||||
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
|
||||
// console.log(corsOrigin, corsMethods, corsHeaders);
|
||||
|
||||
// if (!corsOrigin || !corsMethods || !corsHeaders) {
|
||||
// throw data(
|
||||
// 'Headscale server does not have the required CORS headers for WebSSH',
|
||||
// 500,
|
||||
// );
|
||||
// }
|
||||
|
||||
const nodes = await api.getNodes();
|
||||
const lookupNode = nodes.find((n) => n.givenName === hostname);
|
||||
if (!lookupNode) {
|
||||
throw data(`Node with hostname ${hostname} not found`, 404);
|
||||
}
|
||||
|
||||
// Last thing is keeping track of the ephemeral node in the database
|
||||
// because Headscale doesn't automatically delete ephemeral nodes???
|
||||
const [_ephemeralNode] = await context.db
|
||||
.insert(ephemeralNodes)
|
||||
.values({
|
||||
auth_key: preAuthKey.key,
|
||||
} satisfies EphemeralNodeInsert)
|
||||
.returning();
|
||||
|
||||
return {
|
||||
ipnDetails: {
|
||||
PreAuthKey: preAuthKey.key,
|
||||
Hostname: generateHostname(username),
|
||||
ControlURL: u,
|
||||
},
|
||||
|
||||
sshDetails: {
|
||||
username,
|
||||
hostname,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generateHostname(username: string) {
|
||||
const adjective = faker.word.adjective({
|
||||
length: {
|
||||
min: 3,
|
||||
max: 6,
|
||||
},
|
||||
});
|
||||
|
||||
const noun = faker.word.noun({
|
||||
length: {
|
||||
min: 3,
|
||||
max: 6,
|
||||
},
|
||||
});
|
||||
|
||||
return `ssh-${adjective}-${noun}-${username}`;
|
||||
}
|
||||
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
await context.auth.require(request);
|
||||
if (!context.agents) {
|
||||
throw data("WebSSH is only available with the Headplane agent integration", 400);
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const nodeKey = form.get("node_key");
|
||||
const authKey = form.get("auth_key");
|
||||
|
||||
if (nodeKey === null || typeof nodeKey !== "string") {
|
||||
throw data("Missing node_key", 400);
|
||||
}
|
||||
|
||||
if (authKey === null || typeof authKey !== "string") {
|
||||
throw data("Missing auth_key", 400);
|
||||
}
|
||||
|
||||
await context.db
|
||||
.update(ephemeralNodes)
|
||||
.set({
|
||||
node_key: nodeKey,
|
||||
})
|
||||
.where(eq(ephemeralNodes.auth_key, authKey));
|
||||
|
||||
context.agents?.triggerSync().catch((err) => {
|
||||
log.debug("agent", "Background agent sync failed: %s", err);
|
||||
});
|
||||
}
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{
|
||||
rel: "preload",
|
||||
href: `${__PREFIX__}/hp_ssh.wasm`,
|
||||
as: "fetch",
|
||||
type: "application/wasm",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
];
|
||||
|
||||
export const handle: ExternalScriptsHandle = {
|
||||
scripts: [
|
||||
{
|
||||
src: `${__PREFIX__}/wasm_exec.js`,
|
||||
crossOrigin: "anonymous",
|
||||
preload: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function Page({ loaderData: { ipnDetails, sshDetails } }: Route.ComponentProps) {
|
||||
const submit = useSubmit();
|
||||
const { pause } = useLiveData();
|
||||
|
||||
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
||||
const [nodeKey, setNodeKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ipnDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
pause();
|
||||
const go = new Go(); // Go is defined by wasm_exec.js
|
||||
WebAssembly.instantiateStreaming(fetch(`${__PREFIX__}/hp_ssh.wasm`), go.importObject).then(
|
||||
(value) => {
|
||||
go.run(value.instance);
|
||||
const handle = TsWasmNet(ipnDetails, {
|
||||
NotifyState: (state) => {
|
||||
console.log("State changed:", state);
|
||||
if (state === "Running") {
|
||||
setIpn(handle);
|
||||
}
|
||||
},
|
||||
NotifyNetMap: (netmap) => {
|
||||
// Only set NodeKey if it is not already set and then
|
||||
// also dispatch that to the backend to track the
|
||||
// ephemeral node.
|
||||
//
|
||||
// We open an SSE connection to the backend
|
||||
// so that when the connection is closed,
|
||||
// the backend can delete the ephemeral node.
|
||||
if (nodeKey === null) {
|
||||
setNodeKey(netmap.NodeKey);
|
||||
submit(
|
||||
{
|
||||
node_key: netmap.NodeKey,
|
||||
auth_key: ipnDetails.PreAuthKey,
|
||||
},
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
},
|
||||
NotifyBrowseToURL: (url) => {
|
||||
console.log("Browse to URL:", url);
|
||||
},
|
||||
NotifyPanicRecover: (message) => {
|
||||
console.error("Panic recover:", message);
|
||||
},
|
||||
});
|
||||
|
||||
handle.Start();
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (!sshDetails.username) {
|
||||
return <UserPrompt hostname={sshDetails.hostname} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-mist-900">
|
||||
{ipn === null ? (
|
||||
<div className="mx-auto flex h-screen items-center justify-center">
|
||||
<Loader2 className="size-10 animate-spin text-mist-50" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-screen flex-col">
|
||||
<XTerm hostname={sshDetails.hostname} ipn={ipn} username={sshDetails.username} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
import Card from "~/components/card";
|
||||
import Link from "~/components/link";
|
||||
|
||||
export const sshErrors = {
|
||||
wasm_missing: {
|
||||
title: "Browser SSH is not available",
|
||||
message: "This version of Headplane was not built with browser SSH support.",
|
||||
anchor: "#ssh-not-available",
|
||||
},
|
||||
|
||||
agent_required: {
|
||||
title: "Browser SSH requires the Headplane agent",
|
||||
message: "Browser SSH is only available when the Headplane agent integration is enabled.",
|
||||
anchor: "#agent-required",
|
||||
},
|
||||
|
||||
oidc_required: {
|
||||
title: "Browser SSH requires OIDC authentication",
|
||||
message: "Browser SSH is only available when OIDC authentication is enabled.",
|
||||
anchor: "#oidc-required",
|
||||
},
|
||||
|
||||
node_not_found: (hostname: string) => ({
|
||||
title: "Node not found",
|
||||
message: `No node found with hostname ${hostname}.`,
|
||||
anchor: "#node-not-found",
|
||||
}),
|
||||
|
||||
user_not_linked: {
|
||||
title: "User account not linked",
|
||||
message:
|
||||
"You'll need to link your user account to a Headscale user before you can use Browser SSH.",
|
||||
anchor: "#user-not-linked",
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface SSHErrorBoundaryProps {
|
||||
title: string;
|
||||
message: string;
|
||||
anchor: string;
|
||||
}
|
||||
|
||||
export function isSSHError(error: unknown): error is SSHErrorBoundaryProps {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"title" in error &&
|
||||
"message" in error &&
|
||||
"anchor" in error &&
|
||||
typeof error.title === "string" &&
|
||||
typeof error.message === "string" &&
|
||||
typeof error.anchor === "string"
|
||||
);
|
||||
}
|
||||
|
||||
const DOCS_BASE = "https://headplane.net/features/ssh";
|
||||
|
||||
export function SSHErrorBoundary({ title, message, anchor }: SSHErrorBoundaryProps) {
|
||||
return (
|
||||
<Card className="w-screen" variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>{title}</Card.Title>
|
||||
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<Card.Text>
|
||||
{message}
|
||||
<br />
|
||||
<br />
|
||||
<Link to={`${DOCS_BASE}${anchor}`} external styled>
|
||||
Headplane SSH Documentation
|
||||
</Link>{" "}
|
||||
</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Restty } from "restty";
|
||||
import type { GhosttyTheme } from "restty";
|
||||
import type { PtyTransport } from "restty/internal";
|
||||
|
||||
import type { HeadplaneSSH, TunnelSession } from "./wasm.client";
|
||||
|
||||
const FONT_BASE = `${__PREFIX__}/fonts`;
|
||||
|
||||
// Ghostty's default canvas background is rgb(20,23,26) — a dark gray, not black.
|
||||
// Override it so the terminal matches the page and pane container backgrounds.
|
||||
const HEADPLANE_THEME: GhosttyTheme = {
|
||||
colors: {
|
||||
background: { r: 0, g: 0, b: 0 },
|
||||
foreground: { r: 235, g: 237, b: 242 },
|
||||
palette: [],
|
||||
},
|
||||
raw: {},
|
||||
};
|
||||
|
||||
function createSSHTransport(ssh: HeadplaneSSH, ipAddress: string, username: string): PtyTransport {
|
||||
let session: TunnelSession | null = null;
|
||||
|
||||
return {
|
||||
connect(options) {
|
||||
session = ssh.openTunnel({
|
||||
ipAddress,
|
||||
username,
|
||||
onData: (data) => options.callbacks.onData?.(data),
|
||||
onConnect: () => options.callbacks.onConnect?.(),
|
||||
onDisconnect: () => {
|
||||
options.callbacks.onDisconnect?.();
|
||||
session = null;
|
||||
},
|
||||
});
|
||||
|
||||
if (options.cols && options.rows) {
|
||||
session.resize(options.cols, options.rows);
|
||||
}
|
||||
},
|
||||
disconnect() {
|
||||
session?.close();
|
||||
session = null;
|
||||
},
|
||||
sendInput(data) {
|
||||
session?.writeInput(data);
|
||||
return session != null;
|
||||
},
|
||||
resize(cols, rows) {
|
||||
session?.resize(cols, rows);
|
||||
return session != null;
|
||||
},
|
||||
isConnected() {
|
||||
return session != null;
|
||||
},
|
||||
destroy() {
|
||||
session?.close();
|
||||
session = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface GhosttyProps {
|
||||
ssh: HeadplaneSSH;
|
||||
ipAddress: string;
|
||||
username: string;
|
||||
onConnected: () => void;
|
||||
}
|
||||
|
||||
export default function Ghostty({ ssh, ipAddress, username, onConnected }: GhosttyProps) {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!divRef.current) return;
|
||||
|
||||
const transport = createSSHTransport(ssh, ipAddress, username);
|
||||
const restty = new Restty({
|
||||
root: divRef.current,
|
||||
createInitialPane: true,
|
||||
defaultContextMenu: false,
|
||||
shortcuts: false,
|
||||
searchUi: false,
|
||||
paneStyles: {
|
||||
inactivePaneOpacity: 1,
|
||||
activePaneOpacity: 1,
|
||||
},
|
||||
appOptions: {
|
||||
fontSize: 20,
|
||||
ligatures: true,
|
||||
fontPreset: "none",
|
||||
fontSources: [
|
||||
{
|
||||
type: "url",
|
||||
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Regular.ttf`,
|
||||
label: "JetBrains Mono Nerd Font",
|
||||
},
|
||||
{
|
||||
type: "url",
|
||||
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Bold.ttf`,
|
||||
label: "JetBrains Mono Nerd Font Bold",
|
||||
},
|
||||
{
|
||||
type: "url",
|
||||
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Italic.ttf`,
|
||||
label: "JetBrains Mono Nerd Font Italic",
|
||||
},
|
||||
{
|
||||
type: "url",
|
||||
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf`,
|
||||
label: "JetBrains Mono Nerd Font Bold Italic",
|
||||
},
|
||||
{
|
||||
type: "url",
|
||||
url: `${FONT_BASE}/SymbolsNerdFontMono-Regular.ttf`,
|
||||
label: "Symbols Nerd Font",
|
||||
},
|
||||
],
|
||||
ptyTransport: transport,
|
||||
callbacks: {
|
||||
onPtyStatus: (status) => {
|
||||
if (status === "connected") onConnected();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
restty.applyTheme(HEADPLANE_THEME);
|
||||
restty.updateSize(true);
|
||||
restty.connectPty();
|
||||
|
||||
return () => {
|
||||
restty.destroy();
|
||||
};
|
||||
}, [ssh, ipAddress, username]);
|
||||
|
||||
return <div className="min-h-0 min-w-0 flex-1 overflow-hidden bg-black" ref={divRef} />;
|
||||
}
|
||||
Vendored
-57
@@ -1,57 +0,0 @@
|
||||
declare function TsWasmNet(
|
||||
options: TsWasmNetOptions,
|
||||
callbacks: TsWasmNetCallbacks,
|
||||
): TsWasmNet;
|
||||
|
||||
interface TsWasmNetOptions {
|
||||
ControlURL: string;
|
||||
PreAuthKey: string;
|
||||
Hostname: string;
|
||||
}
|
||||
|
||||
interface TsWasmNetCallbacks {
|
||||
NotifyState: (state: IPNState) => void;
|
||||
NotifyNetMap: (netmap: TsWasmNetMap) => void;
|
||||
NotifyBrowseToURL: (url: string) => void;
|
||||
NotifyPanicRecover: (err: string) => void;
|
||||
}
|
||||
|
||||
interface TsWasmNetMap {
|
||||
NodeKey: string;
|
||||
}
|
||||
|
||||
interface TsWasmNet {
|
||||
Start: () => void;
|
||||
OpenSSH: (
|
||||
hostname: string,
|
||||
username: string,
|
||||
options: XtermConfig,
|
||||
) => SSHSession;
|
||||
}
|
||||
|
||||
type IPNState =
|
||||
| 'NoState'
|
||||
| 'InUseOtherUser'
|
||||
| 'NeedsLogin'
|
||||
| 'NeedsMachineAuth'
|
||||
| 'Stopped'
|
||||
| 'Starting'
|
||||
| 'Running';
|
||||
|
||||
interface XtermConfig {
|
||||
rows: number;
|
||||
cols: number;
|
||||
timeout?: number;
|
||||
|
||||
onStdout: (data: Uint8Array) => void;
|
||||
onStderr: (data: Uint8Array) => void;
|
||||
onStdin: (func: (input: Uint8Array) => void) => void;
|
||||
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
interface SSHSession {
|
||||
Close(): boolean;
|
||||
Resize(rows: number, cols: number): boolean;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { Loader2, WifiOff } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react-router";
|
||||
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
|
||||
|
||||
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 adjective = faker.word.adjective({ length: { min: 3, max: 6 } });
|
||||
const noun = faker.word.noun({ length: { min: 3, max: 6 } });
|
||||
return `ssh-${adjective}-${noun}-${username}`;
|
||||
}
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{
|
||||
rel: "preload",
|
||||
href: WASM_MODULE_URL,
|
||||
as: "fetch",
|
||||
type: "application/wasm",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
];
|
||||
|
||||
export const handle: ExternalScriptsHandle = {
|
||||
scripts: [
|
||||
{
|
||||
src: WASM_HELPER_URL,
|
||||
crossOrigin: "anonymous",
|
||||
preload: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
const { hostname, username, offline, node } = loaderData;
|
||||
|
||||
if (offline) {
|
||||
return (
|
||||
<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">
|
||||
<Card.Title>Node Offline</Card.Title>
|
||||
<WifiOff className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<Card.Text>
|
||||
<Code>{hostname}</Code> is not currently connected to the Tailnet.
|
||||
</Card.Text>
|
||||
<Button className="mt-8 w-full" onClick={() => window.location.reload()}>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!username || !node) {
|
||||
return <UserPrompt hostname={hostname} />;
|
||||
}
|
||||
|
||||
return <SSHConsole hostname={hostname} username={username} node={node} />;
|
||||
}
|
||||
|
||||
function SSHConsole({
|
||||
hostname,
|
||||
username,
|
||||
node,
|
||||
}: {
|
||||
hostname: string;
|
||||
username: string;
|
||||
node: { ipAddress: string; controlURL: string; preAuthKey: string; ephemeralHostname: string };
|
||||
}) {
|
||||
const [ssh, setSsh] = useState<HeadplaneSSH | null>(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),
|
||||
});
|
||||
|
||||
console.log("[ssh] IPN instance created", instance);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [node]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col bg-black">
|
||||
{!connected && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="size-8 animate-spin text-mist-200" />
|
||||
<p className="text-sm text-mist-400">{status}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ssh && (
|
||||
<Ghostty
|
||||
ssh={ssh}
|
||||
username={username}
|
||||
ipAddress={node.ipAddress}
|
||||
onConnected={() => setConnected(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<SSHErrorBoundary
|
||||
title={routeError.title}
|
||||
message={routeError.message}
|
||||
anchor={routeError.anchor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import { Form } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/card";
|
||||
import Code from "~/components/code";
|
||||
import Input from "~/components/input";
|
||||
import Link from "~/components/link";
|
||||
|
||||
interface UserPromptProps {
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
export default function UserPrompt({ hostname }: UserPromptProps) {
|
||||
const [username, setUsername] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Card>
|
||||
@@ -19,27 +18,47 @@ export default function UserPrompt({ hostname }: UserPromptProps) {
|
||||
<Card.Text className="mb-4">
|
||||
Enter the username you want to use to connect to <Code>{hostname}</Code>
|
||||
{". "}
|
||||
WebSSH follows the Headscale ACLs, so only permitted usernames will be able to connect.
|
||||
SSH via the web follows the same ACL rules as regular SSH access in Headscale, so only
|
||||
permitted usernames will work.
|
||||
<br />
|
||||
<br />
|
||||
See the{" "}
|
||||
<Link external styled to="https://headplane.net/features/ssh#troubleshooting">
|
||||
troubleshooting guide
|
||||
</Link>{" "}
|
||||
for common errors.
|
||||
</Card.Text>
|
||||
<Input
|
||||
labelHidden
|
||||
type="text"
|
||||
label="Username"
|
||||
placeholder="Username"
|
||||
className="mb-2"
|
||||
onChange={setUsername}
|
||||
/>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
// We can't use the navigate hook here as we need to do a
|
||||
// full page reload to ensure the SSH connection is established
|
||||
window.location.href = `${__PREFIX__}/ssh?hostname=${hostname}&username=${username}`;
|
||||
<Form
|
||||
method="GET"
|
||||
onSubmit={(e) => {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get("user");
|
||||
if (!username) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// We have to do a full navigation, since the page needs a full
|
||||
// reload to initialize the SSH connection due to us disabling the
|
||||
// revalidator.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("user", username.toString());
|
||||
window.location.assign(url.toString());
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<Input
|
||||
labelHidden
|
||||
type="text"
|
||||
label="Username"
|
||||
name="user"
|
||||
placeholder="Username"
|
||||
className="mb-2"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="heavy" className="w-full">
|
||||
Connect
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
|
||||
|
||||
declare global {
|
||||
type HeadplaneSSHFactory = (config: HeadplaneSSHConfig) => HeadplaneSSH;
|
||||
var __hp_ssh_resolve: ((factory: HeadplaneSSHFactory) => void) | undefined;
|
||||
|
||||
var Go: {
|
||||
new (): {
|
||||
importObject: WebAssembly.Imports;
|
||||
run(instance: WebAssembly.Instance): Promise<void>;
|
||||
argv?: string[];
|
||||
env?: Record<string, string>;
|
||||
exit?: (code: number) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface HeadplaneSSHConfig {
|
||||
controlURL: string;
|
||||
preAuthKey: string;
|
||||
hostname: string;
|
||||
onReady: () => void;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface HeadplaneSSH {
|
||||
openTunnel(config: TunnelConfig): TunnelSession;
|
||||
}
|
||||
|
||||
interface TunnelConfig {
|
||||
ipAddress: string;
|
||||
username: string;
|
||||
timeout?: number;
|
||||
onData: (data: string) => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
export interface TunnelSession {
|
||||
writeInput(data: string): void;
|
||||
resize(cols: number, rows: number): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
let resolvedFactory: Promise<HeadplaneSSHFactory> | null = null;
|
||||
|
||||
/**
|
||||
* One-shot function that loads the Go WASM binary and returns the SSH factory.
|
||||
* It expects the Go WASM helper to be loaded, and will error if called before.
|
||||
*/
|
||||
export async function loadHeadplaneWASM(): Promise<HeadplaneSSHFactory> {
|
||||
if (!resolvedFactory) {
|
||||
const go = new Go();
|
||||
const result = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject);
|
||||
|
||||
resolvedFactory = new Promise<HeadplaneSSHFactory>((resolve) => {
|
||||
globalThis.__hp_ssh_resolve = resolve;
|
||||
});
|
||||
|
||||
go.run(result.instance);
|
||||
}
|
||||
|
||||
return resolvedFactory;
|
||||
}
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
declare class Go {
|
||||
importObject: WebAssembly.Imports;
|
||||
run(instance: WebAssembly.Instance): Promise<void>;
|
||||
argv?: string[];
|
||||
env?: Record<string, string>;
|
||||
exit?: (code: number) => void;
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import * as xterm from "@xterm/xterm";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
import { useLiveData } from "~/utils/live-data";
|
||||
import toast from "~/utils/toast";
|
||||
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
interface XTermProps {
|
||||
ipn: TsWasmNet;
|
||||
username: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
// Go's WASM -> JS crosses realms so we might have to normalize the data under
|
||||
// certain conditions. This also enforces bytes instead of strings being sent.
|
||||
function normU8(data: unknown) {
|
||||
if (data instanceof Uint8Array) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
const any = data as {
|
||||
buffer?: ArrayBufferLike;
|
||||
byteOffset?: number;
|
||||
byteLength?: number;
|
||||
};
|
||||
|
||||
if (any.buffer instanceof ArrayBuffer && typeof any.byteLength === "number") {
|
||||
return new Uint8Array(
|
||||
any.buffer.slice(any.byteOffset ?? 0, (any.byteOffset ?? 0) + any.byteLength),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Data is not a Uint8Array or ArrayBuffer-like object");
|
||||
}
|
||||
|
||||
export default function XTerm({ ipn, username, hostname }: XTermProps) {
|
||||
const { pause } = useLiveData();
|
||||
|
||||
const genRef = useRef(0);
|
||||
const termRef = useRef<xterm.Terminal>(null);
|
||||
const roRef = useRef<ResizeObserver>(null);
|
||||
const inputRef = useRef<(input: Uint8Array) => void>(null);
|
||||
const sshRef = useRef<SSHSession>(null);
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
pause();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!divRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentGen = ++genRef.current;
|
||||
const term = new xterm.Terminal({
|
||||
allowProposedApi: true,
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontSize: 14,
|
||||
});
|
||||
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
|
||||
term.loadAddon(new Unicode11Addon());
|
||||
term.loadAddon(new ClipboardAddon());
|
||||
term.loadAddon(
|
||||
new WebLinksAddon((event, uri) => {
|
||||
event.view?.open(uri, "_blank", "noopener noreferrer");
|
||||
}),
|
||||
);
|
||||
|
||||
term.unicode.activeVersion = "11";
|
||||
termRef.current = term;
|
||||
term.open(divRef.current!);
|
||||
fit.fit();
|
||||
term.focus();
|
||||
|
||||
const session = ipn.OpenSSH(hostname, username, {
|
||||
rows: term.rows,
|
||||
cols: term.cols,
|
||||
onStdout: (data) => {
|
||||
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||
console.warn("Stale terminal instance, ignoring stdout");
|
||||
return;
|
||||
}
|
||||
|
||||
const text = normU8(data);
|
||||
term.write(text);
|
||||
},
|
||||
onStderr: (data) => {
|
||||
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||
console.warn("Stale terminal instance, ignoring stderr");
|
||||
return;
|
||||
}
|
||||
|
||||
const text = normU8(data);
|
||||
term.write(text);
|
||||
const str = new TextDecoder().decode(text);
|
||||
setError(str);
|
||||
},
|
||||
onStdin: (func) => {
|
||||
inputRef.current = func;
|
||||
},
|
||||
onConnect: () => {
|
||||
if (currentGen !== genRef.current) {
|
||||
console.warn("Stale terminal instance, ignoring onConnect");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
},
|
||||
onDisconnect: () => {
|
||||
if (currentGen !== genRef.current) {
|
||||
console.warn("Stale terminal instance, ignoring onDisconnect");
|
||||
return;
|
||||
}
|
||||
|
||||
roRef.current?.disconnect();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
inputRef.current = null;
|
||||
sshRef.current = null;
|
||||
|
||||
setIsLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
sshRef.current = session;
|
||||
const enc = new TextEncoder();
|
||||
term.onData((data) => {
|
||||
if (currentGen !== genRef.current) {
|
||||
console.warn("Stale terminal instance, ignoring onData");
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = enc.encode(data);
|
||||
inputRef.current?.(bytes);
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||
console.warn("Stale terminal instance, ignoring resize");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResizing(true);
|
||||
fit.fit();
|
||||
sshRef.current?.Resize(term.cols, term.rows);
|
||||
setTimeout(() => setIsResizing(false), 100);
|
||||
});
|
||||
|
||||
roRef.current = ro;
|
||||
ro.observe(divRef.current!);
|
||||
|
||||
return () => {
|
||||
++genRef.current;
|
||||
roRef.current?.disconnect();
|
||||
roRef.current = null;
|
||||
|
||||
sshRef.current?.Close();
|
||||
sshRef.current = null;
|
||||
|
||||
term.dispose();
|
||||
if (termRef.current === term) {
|
||||
termRef.current = null;
|
||||
}
|
||||
|
||||
inputRef.current = null;
|
||||
};
|
||||
}, [ipn, username, hostname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<div className="absolute z-50 mx-auto flex h-screen w-screen items-center justify-center">
|
||||
<Loader2 className="size-10 animate-spin text-mist-50" />
|
||||
</div>
|
||||
) : undefined}
|
||||
<div className={cn("w-full h-full", isLoading ? "opacity-0" : "opacity-100")} ref={divRef} />
|
||||
{termRef.current && isResizing ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
|
||||
)}
|
||||
>
|
||||
{termRef.current.cols}x{termRef.current.rows}
|
||||
</div>
|
||||
) : undefined}
|
||||
{error !== null ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center",
|
||||
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
|
||||
)}
|
||||
>
|
||||
Failed to connect to SSH session
|
||||
{error}
|
||||
</div>
|
||||
) : undefined}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -30,8 +30,8 @@ export default function HeadplaneUserRow({
|
||||
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
|
||||
|
||||
return (
|
||||
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
|
||||
<td className="py-2 pl-0.5">
|
||||
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={user.id}>
|
||||
<td className="py-2 pl-2">
|
||||
<div className="flex items-center">
|
||||
{user.profilePicUrl ? (
|
||||
<img alt={displayName} className="h-10 w-10 rounded-full" src={user.profilePicUrl} />
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { eq, isNotNull } from "drizzle-orm";
|
||||
|
||||
import { nodesResource } from "~/server/headscale/live-store";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "../../layout/+types/app";
|
||||
import { ephemeralNodes } from "./schema";
|
||||
|
||||
export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const ephemerals = await context.db
|
||||
.select()
|
||||
.from(ephemeralNodes)
|
||||
.where(isNotNull(ephemeralNodes.node_key));
|
||||
|
||||
if (ephemerals.length === 0) {
|
||||
log.debug("api", "No ephemeral nodes to prune");
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const nodes = await api.getNodes();
|
||||
const toPrune = nodes.filter((node) => {
|
||||
if (node.online) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
|
||||
});
|
||||
|
||||
if (toPrune.length === 0) {
|
||||
log.debug("api", "No SSH nodes to prune");
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete from the Headscale nodes list and then from the database
|
||||
const promises = toPrune.map((node) => {
|
||||
return async () => {
|
||||
log.debug("api", `Pruning node ${node.name}`);
|
||||
await api.deleteNode(node.id);
|
||||
|
||||
await context.db.delete(ephemeralNodes).where(eq(ephemeralNodes.node_key, node.nodeKey));
|
||||
log.debug("api", `Node ${node.name} pruned successfully`);
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(promises.map((p) => p()));
|
||||
|
||||
if (toPrune.length > 0) {
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,6 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import { HostInfo } from "~/types";
|
||||
|
||||
export const ephemeralNodes = sqliteTable("ephemeral_nodes", {
|
||||
auth_key: text("auth_key").primaryKey(),
|
||||
node_key: text("node_key"),
|
||||
});
|
||||
|
||||
export type EphemeralNode = typeof ephemeralNodes.$inferSelect;
|
||||
export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert;
|
||||
|
||||
export const hostInfo = sqliteTable("host_info", {
|
||||
host_id: text("host_id").primaryKey(),
|
||||
payload: text("payload", { mode: "json" }).$type<HostInfo>(),
|
||||
|
||||
+9
-16
@@ -10,7 +10,7 @@ import { HostInfo } from "~/types";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { HeadplaneConfig } from "./config/config-schema";
|
||||
import { ephemeralNodes, hostInfo } from "./db/schema";
|
||||
import { hostInfo } from "./db/schema";
|
||||
import { RuntimeApiClient } from "./headscale/api/endpoints";
|
||||
|
||||
export interface AgentManager {
|
||||
@@ -266,6 +266,10 @@ export async function createAgentManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prunes any offline nodes marked as ephemeral. This is due to a Headscale
|
||||
* bug where ephemeral nodes wouldn't be automatically removed on disconnect.
|
||||
*/
|
||||
async function pruneStaleHostInfo() {
|
||||
try {
|
||||
const nodes = await apiClient.getNodes();
|
||||
@@ -294,23 +298,12 @@ export async function createAgentManager(
|
||||
|
||||
async function pruneEphemeralNodes() {
|
||||
try {
|
||||
const rows = await db.select().from(ephemeralNodes);
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = await apiClient.getNodes();
|
||||
const activeKeys = new Set(nodes.map((n) => n.nodeKey));
|
||||
const toPrune = nodes.filter((n) => n.preAuthKey?.ephemeral && !n.online);
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.node_key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!activeKeys.has(row.node_key)) {
|
||||
await db.delete(ephemeralNodes).where(inArray(ephemeralNodes.auth_key, [row.auth_key]));
|
||||
log.info("agent", "Pruned ephemeral SSH node %s", row.node_key);
|
||||
}
|
||||
for (const node of toPrune) {
|
||||
await apiClient.deleteNode(node.id);
|
||||
log.info("agent", "Pruned offline ephemeral node %s", node.givenName);
|
||||
}
|
||||
} catch (error) {
|
||||
log.debug(
|
||||
|
||||
Reference in New Issue
Block a user