mirror of
https://github.com/tale/headplane.git
synced 2026-08-09 21:49:23 +00:00
feat: cleanup and streamline webssh capability
This commit is contained in:
+121
-119
@@ -1,82 +1,95 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { useState } from 'react';
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
ShouldRevalidateFunction,
|
||||
data,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import { PreAuthKey, User } from '~/types';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import XTerm from './xterm.client';
|
||||
|
||||
import wasm from '~/hp_ssh.wasm?url';
|
||||
import '~/wasm_exec';
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
// const session = await context.sessions.auth(request);
|
||||
// const user = session.get('user');
|
||||
// if (!user) {
|
||||
// throw data('Unauthorized', 401);
|
||||
// }
|
||||
// if (user.subject === 'unknown-non-oauth') {
|
||||
// throw data('Only OAuth users are allowed to use WebSSH', 403);
|
||||
// }
|
||||
// const { users } = await context.client.get<{ users: User[] }>(
|
||||
// 'v1/user',
|
||||
// session.get('api_key')!,
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.get('user');
|
||||
if (!user) {
|
||||
throw data('Unauthorized', 401);
|
||||
}
|
||||
if (user.subject === 'unknown-non-oauth') {
|
||||
throw data('Only OAuth users are allowed to use WebSSH', 403);
|
||||
}
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.get('api_key')!,
|
||||
);
|
||||
// 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 = users.find((u) => {
|
||||
const subject = u.providerId?.split('/').pop();
|
||||
if (!subject) {
|
||||
return false;
|
||||
}
|
||||
return subject === user.subject;
|
||||
});
|
||||
if (!lookup) {
|
||||
throw data(
|
||||
`User with subject ${user.subject} not found within Headscale`,
|
||||
404,
|
||||
);
|
||||
}
|
||||
const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey',
|
||||
session.get('api_key')!,
|
||||
{
|
||||
user: lookup.id,
|
||||
reusable: false,
|
||||
ephemeral: true,
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
|
||||
},
|
||||
);
|
||||
// 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,
|
||||
// );
|
||||
// // 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 = users.find((u) => {
|
||||
// const subject = u.providerId?.split('/').pop();
|
||||
// if (!subject) {
|
||||
// return false;
|
||||
// }
|
||||
// return subject === user.subject;
|
||||
// });
|
||||
// if (!lookup) {
|
||||
// throw data(
|
||||
// `User with subject ${user.subject} not found within Headscale`,
|
||||
// 404,
|
||||
// );
|
||||
// }
|
||||
// const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||
// 'v1/preauthkey',
|
||||
// session.get('api_key')!,
|
||||
// {
|
||||
// user: lookup.id,
|
||||
// reusable: false,
|
||||
// ephemeral: true,
|
||||
// expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
|
||||
// },
|
||||
// );
|
||||
// // 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;
|
||||
// const port = qp.get('port')
|
||||
// ? Number.parseInt(qp.get('port')!, 10)
|
||||
// : undefined;
|
||||
// if (!username || !hostname || !port) {
|
||||
// throw data('Missing required parameters: username, hostname, port', 400);
|
||||
// }
|
||||
// // TODO: Verify the Host headers to ensure CORS friendly
|
||||
// // TODO: Check if the URL actually resolves correctly
|
||||
// // TODO: Keep track of hostname since ephemeral nodes are broken atm
|
||||
// return {
|
||||
// PreAuthKey: preAuthKey.key,
|
||||
// ControlURL:
|
||||
// context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
// Hostname: generateHostname(username),
|
||||
// ssh: {
|
||||
// username,
|
||||
// hostname,
|
||||
// },
|
||||
// };
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const username = qp.get('username') || undefined;
|
||||
const hostname = qp.get('hostname') || undefined;
|
||||
const port = qp.get('port')
|
||||
? Number.parseInt(qp.get('port')!, 10)
|
||||
: undefined;
|
||||
if (!username || !hostname || !port) {
|
||||
throw data('Missing required parameters: username, hostname, port', 400);
|
||||
}
|
||||
// TODO: Verify the Host headers to ensure CORS friendly
|
||||
// TODO: Check if the URL actually resolves correctly
|
||||
// TODO: Keep track of hostname since ephemeral nodes are broken atm
|
||||
return {
|
||||
PreAuthKey: preAuthKey.key,
|
||||
ControlURL:
|
||||
context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
Hostname: generateHostname(username),
|
||||
ssh: {
|
||||
username,
|
||||
hostname,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generateHostname(username: string) {
|
||||
@@ -98,46 +111,46 @@ function generateHostname(username: string) {
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
// const { pause } = useLiveData();
|
||||
const { pause } = useLiveData();
|
||||
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
||||
// const { PreAuthKey, ControlURL, Hostname, ssh } =
|
||||
// useLoaderData<typeof loader>();
|
||||
const { PreAuthKey, ControlURL, Hostname, ssh } =
|
||||
useLoaderData<typeof loader>();
|
||||
|
||||
// useEffect(() => {
|
||||
// pause();
|
||||
// const go = new Go(); // Go is defined by wasm_exec.js
|
||||
// WebAssembly.instantiateStreaming(fetch(wasm), go.importObject).then(
|
||||
// (value) => {
|
||||
// go.run(value.instance);
|
||||
// const handle = TsWasmNet(
|
||||
// {
|
||||
// PreAuthKey,
|
||||
// ControlURL,
|
||||
// Hostname,
|
||||
// },
|
||||
// {
|
||||
// NotifyState: (state) => {
|
||||
// console.log('State changed:', state);
|
||||
// if (state === 'Running') {
|
||||
// setIpn(handle);
|
||||
// }
|
||||
// },
|
||||
// NotifyNetMap: (netmap) => {
|
||||
// console.log('NetMap updated:', netmap);
|
||||
// },
|
||||
// NotifyBrowseToURL: (url) => {
|
||||
// console.log('Browse to URL:', url);
|
||||
// },
|
||||
// NotifyPanicRecover: (message) => {
|
||||
// console.error('Panic recover:', message);
|
||||
// },
|
||||
// },
|
||||
// );
|
||||
useEffect(() => {
|
||||
pause();
|
||||
const go = new Go(); // Go is defined by wasm_exec.js
|
||||
WebAssembly.instantiateStreaming(fetch(wasm), go.importObject).then(
|
||||
(value) => {
|
||||
go.run(value.instance);
|
||||
const handle = TsWasmNet(
|
||||
{
|
||||
PreAuthKey,
|
||||
ControlURL,
|
||||
Hostname,
|
||||
},
|
||||
{
|
||||
NotifyState: (state) => {
|
||||
console.log('State changed:', state);
|
||||
if (state === 'Running') {
|
||||
setIpn(handle);
|
||||
}
|
||||
},
|
||||
NotifyNetMap: (netmap) => {
|
||||
console.log('NetMap updated:', netmap);
|
||||
},
|
||||
NotifyBrowseToURL: (url) => {
|
||||
console.log('Browse to URL:', url);
|
||||
},
|
||||
NotifyPanicRecover: (message) => {
|
||||
console.error('Panic recover:', message);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// handle.Start();
|
||||
// },
|
||||
// );
|
||||
// }, []);
|
||||
handle.Start();
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen bg-headplane-900">
|
||||
@@ -147,18 +160,7 @@ export default function Page() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-screen">
|
||||
{/* <h1>Session ID: {sessionId}</h1>
|
||||
{queue.current.length > 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{queue.current.length} frames queued
|
||||
</p>
|
||||
)} */}
|
||||
|
||||
{/* <XTerm ipn={ipn} /> */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Render your terminal component here */}
|
||||
{/* <Terminal ipn={ipn} /> */}
|
||||
</div>
|
||||
<XTerm ipn={ipn} username={ssh.username} hostname={ssh.hostname} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Vendored
+29
-13
@@ -1,4 +1,3 @@
|
||||
declare function newIPN(config: NewIPNConfig): IPNHandle;
|
||||
declare function TsWasmNet(
|
||||
options: TsWasmNetOptions,
|
||||
callbacks: TsWasmNetCallbacks,
|
||||
@@ -19,6 +18,11 @@ interface TsWasmNetCallbacks {
|
||||
|
||||
interface TsWasmNet {
|
||||
Start: () => void;
|
||||
OpenSSH: (
|
||||
hostname: string,
|
||||
username: string,
|
||||
options: XtermConfig,
|
||||
) => SSHSession;
|
||||
}
|
||||
|
||||
type IPNState =
|
||||
@@ -30,19 +34,31 @@ type IPNState =
|
||||
| 'Starting'
|
||||
| 'Running';
|
||||
|
||||
interface SSHTerminalConfig {
|
||||
writeFn: (data: string) => void;
|
||||
writeErrorFn: (error: string) => void;
|
||||
setReadFn: (cb: (input: string) => void) => void;
|
||||
rows: number;
|
||||
cols: number;
|
||||
timeoutSeconds?: number;
|
||||
onConnectionProgress: (message: string) => void;
|
||||
onConnected: () => void;
|
||||
onDone: () => void;
|
||||
interface XtermConfig {
|
||||
Rows: number;
|
||||
Cols: number;
|
||||
|
||||
OnStdout: (data: string) => void;
|
||||
OnStderr: (data: string) => void;
|
||||
OnStdin: (func: (input: string) => void) => void;
|
||||
|
||||
OnConnect: () => void;
|
||||
OnDisconnect: () => void;
|
||||
}
|
||||
|
||||
// interface SSHTerminalConfig {
|
||||
// writeFn: (data: string) => void;
|
||||
// writeErrorFn: (error: string) => void;
|
||||
// setReadFn: (cb: (input: string) => void) => void;
|
||||
// rows: number;
|
||||
// cols: number;
|
||||
// timeoutSeconds?: number;
|
||||
// onConnectionProgress: (message: string) => void;
|
||||
// onConnected: () => void;
|
||||
// onDone: () => void;
|
||||
// }
|
||||
|
||||
interface SSHSession {
|
||||
close(): boolean;
|
||||
resize(rows: number, cols: number): boolean;
|
||||
Close(): boolean;
|
||||
Resize(rows: number, cols: number): boolean;
|
||||
}
|
||||
|
||||
@@ -4,36 +4,30 @@ import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import * as xterm from '@xterm/xterm';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { decode, encode } from 'cborg';
|
||||
import type {
|
||||
SSHDataCommand,
|
||||
SSHFrameData,
|
||||
SSHResizeCommand,
|
||||
} from '~/server/agent/dispatcher';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
|
||||
interface XTermProps {
|
||||
ws: WebSocket;
|
||||
sessionId: string;
|
||||
queue: Array<Uint8Array>;
|
||||
ipn: TsWasmNet;
|
||||
username: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
const RED = new TextEncoder().encode('\x1b[31m');
|
||||
const RESET = new TextEncoder().encode('\x1b[0m');
|
||||
|
||||
export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
export default function XTerm({ ipn, username, hostname }: XTermProps) {
|
||||
const { pause } = useLiveData();
|
||||
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const term = useRef<xterm.Terminal>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const inputRef = useRef<((input: string) => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
pause();
|
||||
|
||||
const terminal = new xterm.Terminal({
|
||||
allowProposedApi: true,
|
||||
cursorBlink: true,
|
||||
@@ -49,9 +43,13 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.loadAddon(new ClipboardAddon());
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
terminal.unicode.activeVersion = '11';
|
||||
terminal.loadAddon(
|
||||
new WebLinksAddon((event, uri) => {
|
||||
event.view?.open(uri, '_blank', 'noopener noreferrer');
|
||||
}),
|
||||
);
|
||||
|
||||
terminal.unicode.activeVersion = '11';
|
||||
const gl = new WebglAddon();
|
||||
terminal.loadAddon(gl);
|
||||
|
||||
@@ -67,97 +65,61 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
terminal.focus();
|
||||
term.current = terminal;
|
||||
|
||||
const handleFrame = (data: Uint8Array) => {
|
||||
try {
|
||||
const frame: SSHFrameData = decode(data);
|
||||
if (frame.op !== 'ssh_frame') {
|
||||
console.warn('Received unexpected frame type:', frame.op);
|
||||
return;
|
||||
let ro: ResizeObserver | null = null;
|
||||
let onUnload: ((e: Event) => void) | null = null;
|
||||
|
||||
const session = ipn.OpenSSH(hostname, username, {
|
||||
Rows: terminal.rows,
|
||||
Cols: terminal.cols,
|
||||
|
||||
OnStdout: (data) => terminal.write(data),
|
||||
OnStderr: (data) => {
|
||||
terminal.write(data);
|
||||
console.log('SSH stderr:', data);
|
||||
},
|
||||
OnStdin: (func) => {
|
||||
inputRef.current = func;
|
||||
},
|
||||
OnConnect: () => {
|
||||
console.log('SSH session connected');
|
||||
},
|
||||
|
||||
OnDisconnect: () => {
|
||||
ro?.disconnect();
|
||||
terminal.dispose();
|
||||
if (onUnload) {
|
||||
parent.removeEventListener('unload', onUnload);
|
||||
}
|
||||
|
||||
// If this is stderr, color it red
|
||||
if (frame.payload.channel === 2) {
|
||||
terminal.write(
|
||||
new Uint8Array([...RED, ...frame.payload.frame, ...RESET]),
|
||||
);
|
||||
} else {
|
||||
terminal.write(frame.payload.frame);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode CBOR frame:', err);
|
||||
console.log('SSH session disconnected');
|
||||
term.current = null;
|
||||
},
|
||||
});
|
||||
|
||||
const parent = container.current?.ownerDocument.defaultView ?? window;
|
||||
ro = new parent.ResizeObserver(() => {
|
||||
if (term.current) {
|
||||
setIsResizing(true);
|
||||
fit.fit();
|
||||
setTimeout(() => setIsResizing(false), 100);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
for (const buffer of queue) {
|
||||
handleFrame(buffer);
|
||||
if (container.current) {
|
||||
ro.observe(container.current);
|
||||
}
|
||||
|
||||
terminal.onData((input) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
encode({
|
||||
op: 'ssh_data',
|
||||
payload: {
|
||||
sessionId,
|
||||
data: new TextEncoder().encode(input),
|
||||
},
|
||||
} satisfies SSHDataCommand),
|
||||
);
|
||||
} else {
|
||||
console.warn('WebSocket is not open, cannot send data');
|
||||
}
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
session.Resize(rows, cols);
|
||||
});
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (!(event.data instanceof ArrayBuffer)) {
|
||||
console.warn('Received non-binary message from WebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
const size = event.data.byteLength;
|
||||
console.log(`[WS] Received ${size} bytes at ${Date.now()}`);
|
||||
|
||||
const data = new Uint8Array(event.data);
|
||||
handleFrame(data);
|
||||
};
|
||||
|
||||
ws.addEventListener('message', onMessage);
|
||||
const ro = new ResizeObserver(() => {
|
||||
const before = {
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
};
|
||||
|
||||
fit.fit();
|
||||
if (before.cols !== terminal.cols || before.rows !== terminal.rows) {
|
||||
console.log(
|
||||
`Resized terminal to ${terminal.cols} cols and ${terminal.rows} rows`,
|
||||
);
|
||||
ws.send(
|
||||
encode({
|
||||
op: 'ssh_resize',
|
||||
payload: {
|
||||
sessionId,
|
||||
width: terminal.cols,
|
||||
height: terminal.rows,
|
||||
},
|
||||
} satisfies SSHResizeCommand),
|
||||
);
|
||||
|
||||
setIsResizing(true);
|
||||
setTimeout(() => {
|
||||
setIsResizing(false);
|
||||
}, 1000);
|
||||
}
|
||||
terminal.onData((data) => {
|
||||
inputRef.current?.(data);
|
||||
});
|
||||
|
||||
ro.observe(container.current!);
|
||||
return () => {
|
||||
ws.removeEventListener('message', onMessage);
|
||||
term.current?.dispose();
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [ws, queue]);
|
||||
onUnload = (_) => session.Close();
|
||||
parent.addEventListener('unload', onUnload);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full group">
|
||||
|
||||
Reference in New Issue
Block a user