mirror of
https://github.com/tale/headplane.git
synced 2026-08-16 00:13:14 +00:00
chore: reorganize go code
This commit is contained in:
+1
-1
@@ -10,6 +10,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'),
|
||||
|
||||
// All the main logged-in dashboard routes
|
||||
// Double nested to separate error propagations
|
||||
@@ -25,7 +26,6 @@ export default [
|
||||
route('/users', 'routes/users/overview.tsx'),
|
||||
route('/acls', 'routes/acls/overview.tsx'),
|
||||
route('/dns', 'routes/dns/overview.tsx'),
|
||||
route('/ssh', 'routes/ssh/overview.tsx'),
|
||||
|
||||
...prefix('/settings', [
|
||||
index('routes/settings/overview.tsx'),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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 '~/wasm_exec';
|
||||
|
||||
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')!,
|
||||
// );
|
||||
// // 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,
|
||||
// },
|
||||
// };
|
||||
}
|
||||
|
||||
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 default function Page() {
|
||||
// const { pause } = useLiveData();
|
||||
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
||||
// 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);
|
||||
// },
|
||||
// },
|
||||
// );
|
||||
|
||||
// handle.Start();
|
||||
// },
|
||||
// );
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen bg-headplane-900">
|
||||
{ipn === null ? (
|
||||
<div className="mx-auto h-screen flex items-center justify-center">
|
||||
<Loader2 className="animate-spin size-10" />
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
declare function newIPN(config: NewIPNConfig): IPNHandle;
|
||||
declare function TsWasmNet(
|
||||
options: TsWasmNetOptions,
|
||||
callbacks: TsWasmNetCallbacks,
|
||||
): TsWasmNet;
|
||||
|
||||
interface TsWasmNetOptions {
|
||||
ControlURL: string;
|
||||
PreAuthKey: string;
|
||||
Hostname: string;
|
||||
}
|
||||
|
||||
interface TsWasmNetCallbacks {
|
||||
NotifyState: (state: IPNState) => void;
|
||||
NotifyNetMap: (netMapJson: string) => void;
|
||||
NotifyBrowseToURL: (url: string) => void;
|
||||
NotifyPanicRecover: (err: string) => void;
|
||||
}
|
||||
|
||||
interface TsWasmNet {
|
||||
Start: () => void;
|
||||
}
|
||||
|
||||
type IPNState =
|
||||
| 'NoState'
|
||||
| 'InUseOtherUser'
|
||||
| 'NeedsLogin'
|
||||
| 'NeedsMachineAuth'
|
||||
| 'Stopped'
|
||||
| '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 SSHSession {
|
||||
close(): boolean;
|
||||
resize(rows: number, cols: number): boolean;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { decode } from 'cborg';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { LoaderFunctionArgs, data, useLoaderData } from 'react-router';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import {
|
||||
Command,
|
||||
SSHConnectData,
|
||||
SSHConnectFailedData,
|
||||
} from '~/server/agent/dispatcher';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import toast from '~/utils/toast';
|
||||
import XTerm from './xterm.client';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
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);
|
||||
}
|
||||
|
||||
const baseUrl = new URL(request.url).origin;
|
||||
const wsUrl = new URL('/_ssh_plexer', baseUrl);
|
||||
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl.searchParams.set('username', username);
|
||||
wsUrl.searchParams.set('hostname', hostname);
|
||||
wsUrl.searchParams.set('port', port.toString());
|
||||
|
||||
return {
|
||||
socketUrl: wsUrl.toString(),
|
||||
};
|
||||
}
|
||||
type SessionStatus = 'loading' | 'connected' | 'error';
|
||||
|
||||
export default function Page() {
|
||||
const { pause } = useLiveData();
|
||||
const { socketUrl } = useLoaderData<typeof loader>();
|
||||
const [socket, setSocket] = useState<WebSocket | null>(null);
|
||||
const [status, setStatus] = useState<SessionStatus>('loading');
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
|
||||
const queue = useRef<Array<Uint8Array>>([]);
|
||||
const validated = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
// SSH connections should not use stale while revalidate logic.
|
||||
pause();
|
||||
|
||||
const ws = new WebSocket(socketUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
setSocket(ws);
|
||||
setStatus('loading');
|
||||
};
|
||||
|
||||
// We need to wait for the WebSocket to open and respond with the
|
||||
// connection ID. Without a session ID, we do not have a mux.
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (!(event.data instanceof ArrayBuffer)) {
|
||||
toast('Invalid message received from server');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new Uint8Array(event.data);
|
||||
const obj = decode(data) as Command;
|
||||
|
||||
if (obj.op === 'ssh_conn_successful') {
|
||||
const data = obj as SSHConnectData;
|
||||
if (!validated.current) {
|
||||
validated.current = true;
|
||||
toast(
|
||||
`SSH connection established with session ID: ${data.payload.sessionId}`,
|
||||
);
|
||||
|
||||
setStatus('connected');
|
||||
setSessionId(data.payload.sessionId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (obj.op === 'ssh_conn_failed') {
|
||||
const data = obj as SSHConnectFailedData;
|
||||
if (!validated.current) {
|
||||
validated.current = true;
|
||||
toast(`SSH connection failed: ${data.payload.reason}`);
|
||||
setStatus('error');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (obj.op === 'ssh_frame') {
|
||||
queue.current.push(new Uint8Array(event.data));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener('message', messageHandler);
|
||||
|
||||
ws.onerror = (error) => {
|
||||
setStatus('error');
|
||||
toast(`WebSocket error: ${error}`);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (status !== 'error') {
|
||||
toast('SSH connection closed');
|
||||
}
|
||||
|
||||
setSocket(null);
|
||||
setStatus('error');
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.removeEventListener('message', messageHandler);
|
||||
ws.close();
|
||||
};
|
||||
}, [socketUrl]);
|
||||
|
||||
if (socket === null || !sessionId || status === 'loading') {
|
||||
return (
|
||||
<Loader2 className="animate-spin text-gray-500 dark:text-gray-400 w-6 h-6 mx-auto mt-4" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<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 ws={socket} sessionId={sessionId} queue={queue.current} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare class Go {
|
||||
importObject: WebAssembly.Imports;
|
||||
run(instance: WebAssembly.Instance): Promise<void>;
|
||||
argv?: string[];
|
||||
env?: Record<string, string>;
|
||||
exit?: (code: number) => void;
|
||||
}
|
||||
@@ -114,6 +114,9 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size = event.data.byteLength;
|
||||
console.log(`[WS] Received ${size} bytes at ${Date.now()}`);
|
||||
|
||||
const data = new Uint8Array(event.data);
|
||||
handleFrame(data);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user