feat: add xterm frontend ui

This commit is contained in:
Aarnav Tale
2025-05-31 12:56:20 -04:00
parent 55eacb59e9
commit cb32637938
9 changed files with 325 additions and 16 deletions
+22 -8
View File
@@ -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
+28
View File
@@ -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)
}
+1
View File
@@ -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'),
+145
View File
@@ -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>
);
}
+91
View File
@@ -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%' }} />;
}
+12 -5
View File
@@ -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
View File
@@ -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,
},
});
});
+1
View File
@@ -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",
+8
View File
@@ -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