mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat: add xterm frontend ui
This commit is contained in:
@@ -37,7 +37,6 @@ func FollowMaster(agent *tsnet.TSAgent) {
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
log.Info("Got bytes delimited by newline")
|
||||
|
||||
var msg CborMessage
|
||||
decoder := cbor.NewDecoder(bytes.NewReader(line))
|
||||
@@ -49,14 +48,29 @@ func FollowMaster(agent *tsnet.TSAgent) {
|
||||
}
|
||||
|
||||
log.Debug("Received message from master: %s", msg)
|
||||
var sshPayload sshutil.SSHConnectPayload
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH connect payload: %s", err)
|
||||
continue
|
||||
}
|
||||
switch msg.Op {
|
||||
case "ssh_conn":
|
||||
var sshPayload sshutil.SSHConnectPayload
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH connect payload: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
sshutil.StartWebSSH(agent, sshPayload)
|
||||
sshutil.StartWebSSH(agent, sshPayload)
|
||||
continue
|
||||
|
||||
case "ssh_term":
|
||||
var sshPayload sshutil.SSHClosePayload
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH close payload: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
sshutil.CloseWebSSH(agent, sshPayload)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// var msg RecvMessage
|
||||
|
||||
@@ -18,6 +18,10 @@ type SSHConnectPayload struct {
|
||||
Port int `cbor:"port"`
|
||||
}
|
||||
|
||||
type SSHClosePayload struct {
|
||||
SessionId string `cbor:"sessionId"`
|
||||
}
|
||||
|
||||
func connectToTailscaleSSH(agent *tsnet.TSAgent, params SSHConnectPayload) (*ssh.Client, error) {
|
||||
log := util.GetLogger()
|
||||
addr := strings.Join([]string{params.Hostname, ":", strconv.Itoa(params.Port)}, "")
|
||||
@@ -136,3 +140,27 @@ func StartWebSSH(agent *tsnet.TSAgent, params SSHConnectPayload) {
|
||||
RemoveSession(params.SessionId)
|
||||
}()
|
||||
}
|
||||
|
||||
func CloseWebSSH(agent *tsnet.TSAgent, params SSHClosePayload) {
|
||||
log := util.GetLogger()
|
||||
|
||||
if agent == nil {
|
||||
log.Error("tsnet.TSAgent is not initialized correctly")
|
||||
return
|
||||
}
|
||||
|
||||
if params.SessionId == "" {
|
||||
log.Error("Invalid SSH close parameters: %v", params)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("Closing SSH session for session ID: %s", params.SessionId)
|
||||
ctx, ok := lookupSession(params.SessionId)
|
||||
if !ok {
|
||||
log.Info("No active SSH session found for session ID: %s", params.SessionId)
|
||||
return
|
||||
}
|
||||
|
||||
RemoveSession(ctx.ID)
|
||||
log.Info("SSH session for %s closed", params.SessionId)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ 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,145 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as xterm from '@xterm/xterm';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { decode } from 'cborg';
|
||||
import type { SSHFrameData } from '~/server/agent/dispatcher';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
|
||||
interface XTermProps {
|
||||
ws: WebSocket;
|
||||
sessionId: string;
|
||||
queue: Array<Uint8Array>;
|
||||
}
|
||||
|
||||
const RED = new TextEncoder().encode('\x1b[31m');
|
||||
const RESET = new TextEncoder().encode('\x1b[0m');
|
||||
|
||||
export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
const { pause } = useLiveData();
|
||||
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const term = useRef<xterm.Terminal>(null);
|
||||
|
||||
useEffect(() => {
|
||||
pause();
|
||||
|
||||
const terminal = new xterm.Terminal({
|
||||
convertEol: true,
|
||||
fontSize: 14,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
terminal.open(container.current!);
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
for (const buffer of queue) {
|
||||
handleFrame(buffer);
|
||||
}
|
||||
|
||||
terminal.onData((input) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(input);
|
||||
} else {
|
||||
console.warn('WebSocket is not open, cannot send data');
|
||||
}
|
||||
});
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (!(event.data instanceof ArrayBuffer)) {
|
||||
console.warn('Received non-binary message from WebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new Uint8Array(event.data);
|
||||
handleFrame(data);
|
||||
};
|
||||
|
||||
ws.addEventListener('message', onMessage);
|
||||
|
||||
return () => {
|
||||
ws.removeEventListener('message', onMessage);
|
||||
term.current?.dispose();
|
||||
};
|
||||
}, [ws, queue]);
|
||||
|
||||
return <div ref={container} style={{ height: '100%', width: '100%' }} />;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { encode } from 'cborg';
|
||||
import { WSContext } from 'hono/ws';
|
||||
import { ChannelType } from './encoder';
|
||||
|
||||
interface Command {
|
||||
export interface Command {
|
||||
op: string;
|
||||
payload: unknown;
|
||||
}
|
||||
@@ -18,7 +18,14 @@ interface SSHConnectCommand extends Command {
|
||||
};
|
||||
}
|
||||
|
||||
type AgentCommand = SSHConnectCommand;
|
||||
interface SSHCloseCommand extends Command {
|
||||
op: 'ssh_term';
|
||||
payload: {
|
||||
sessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
type AgentCommand = SSHConnectCommand | SSHCloseCommand;
|
||||
|
||||
export async function dispatchCommand(
|
||||
dispatcher: Writable,
|
||||
@@ -36,21 +43,21 @@ export async function dispatchCommand(
|
||||
});
|
||||
}
|
||||
|
||||
interface SSHConnectData extends Command {
|
||||
export interface SSHConnectData extends Command {
|
||||
op: 'ssh_conn_successful';
|
||||
payload: {
|
||||
sessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SSHConnectFailedData extends Command {
|
||||
export interface SSHConnectFailedData extends Command {
|
||||
op: 'ssh_conn_failed';
|
||||
payload: {
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SSHFrameData extends Command {
|
||||
export interface SSHFrameData extends Command {
|
||||
op: 'ssh_frame';
|
||||
payload: {
|
||||
channel: ChannelType;
|
||||
|
||||
+17
-3
@@ -143,22 +143,36 @@ export class SSHMultiplexer {
|
||||
this.sshInput.write(encodedFrame);
|
||||
},
|
||||
|
||||
onClose: (_, ws) => {
|
||||
onClose: async (_, ws) => {
|
||||
const sessionId = ws.raw;
|
||||
if (sessionId && this.connections.has(sessionId)) {
|
||||
const session = this.connections.get(sessionId);
|
||||
if (session) {
|
||||
await dispatchCommand(this.control, {
|
||||
op: 'ssh_term',
|
||||
payload: {
|
||||
sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
session.connected = false;
|
||||
this.connections.delete(sessionId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onError: (event, ws) => {
|
||||
onError: async (event, ws) => {
|
||||
const sessionId = ws.raw;
|
||||
if (sessionId && this.connections.has(sessionId)) {
|
||||
const session = this.connections.get(sessionId);
|
||||
if (session) {
|
||||
await dispatchCommand(this.control, {
|
||||
op: 'ssh_term',
|
||||
payload: {
|
||||
sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
session.connected = false;
|
||||
this.connections.delete(sessionId);
|
||||
}
|
||||
@@ -191,7 +205,7 @@ export class SSHMultiplexer {
|
||||
op: 'ssh_frame',
|
||||
payload: {
|
||||
channel: frame.channel,
|
||||
data: frame.payload,
|
||||
frame: frame.payload,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@uiw/codemirror-theme-github": "^4.23.12",
|
||||
"@uiw/codemirror-theme-xcode": "^4.23.12",
|
||||
"@uiw/react-codemirror": "^4.23.12",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"arktype": "^2.1.20",
|
||||
"cborg": "^4.2.11",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
+8
@@ -61,6 +61,9 @@ importers:
|
||||
'@uiw/react-codemirror':
|
||||
specifier: ^4.23.12
|
||||
version: 4.23.12(@babel/runtime@7.27.3)(@codemirror/autocomplete@6.18.2(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.36.8)(@lezer/common@1.2.3))(@codemirror/language@6.11.0)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.36.8)(codemirror@6.0.1(@lezer/common@1.2.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@xterm/xterm':
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
arktype:
|
||||
specifier: ^2.1.20
|
||||
version: 2.1.20
|
||||
@@ -1591,6 +1594,9 @@ packages:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@xterm/xterm@5.5.0':
|
||||
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
|
||||
|
||||
acorn@8.14.1:
|
||||
resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -4713,6 +4719,8 @@ snapshots:
|
||||
- '@codemirror/lint'
|
||||
- '@codemirror/search'
|
||||
|
||||
'@xterm/xterm@5.5.0': {}
|
||||
|
||||
acorn@8.14.1:
|
||||
optional: true
|
||||
|
||||
|
||||
Reference in New Issue
Block a user