feat: cleanup and streamline webssh capability

This commit is contained in:
Aarnav Tale
2025-06-09 17:46:09 -04:00
parent 0f9bf73b82
commit f4af5b920d
8 changed files with 589 additions and 862 deletions
+121 -119
View File
@@ -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>