mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
feat: support resizing and other xterm.js addons
This commit is contained in:
@@ -70,6 +70,17 @@ func FollowMaster(agent *tsnet.TSAgent) {
|
||||
|
||||
sshutil.CloseWebSSH(agent, sshPayload)
|
||||
continue
|
||||
|
||||
case "ssh_resize":
|
||||
var sshPayload sshutil.SSHResizePayload
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH resize payload: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
sshutil.ResizeWebSSH(agent, sshPayload)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ type SSHClosePayload struct {
|
||||
SessionId string `cbor:"sessionId"`
|
||||
}
|
||||
|
||||
type SSHResizePayload struct {
|
||||
SessionId string `cbor:"sessionId"`
|
||||
Width int `cbor:"width"`
|
||||
Height int `cbor:"height"`
|
||||
}
|
||||
|
||||
func connectToTailscaleSSH(agent *tsnet.TSAgent, params SSHConnectPayload) (*ssh.Client, error) {
|
||||
log := util.GetLogger()
|
||||
addr := strings.Join([]string{params.Hostname, ":", strconv.Itoa(params.Port)}, "")
|
||||
@@ -96,7 +102,7 @@ func StartWebSSH(agent *tsnet.TSAgent, params SSHConnectPayload) {
|
||||
}
|
||||
|
||||
// Resize event is possible via the control channel later
|
||||
err = sess.RequestPty("xterm-256color", 80, 40, modes)
|
||||
err = sess.RequestPty("xterm-256color", 24, 80, modes)
|
||||
if err != nil {
|
||||
log.Error("Failed to request PTY for (%s): %s", params.SessionId, err)
|
||||
return
|
||||
@@ -164,3 +170,32 @@ func CloseWebSSH(agent *tsnet.TSAgent, params SSHClosePayload) {
|
||||
RemoveSession(ctx.ID)
|
||||
log.Info("SSH session for %s closed", params.SessionId)
|
||||
}
|
||||
|
||||
func ResizeWebSSH(agent *tsnet.TSAgent, params SSHResizePayload) {
|
||||
log := util.GetLogger()
|
||||
|
||||
if agent == nil {
|
||||
log.Error("tsnet.TSAgent is not initialized correctly")
|
||||
return
|
||||
}
|
||||
|
||||
if params.SessionId == "" || params.Width <= 0 || params.Height <= 0 {
|
||||
log.Error("Invalid SSH resize parameters: %v", params)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("Resizing SSH session for session ID: %s to %dx%d", params.SessionId, params.Width, params.Height)
|
||||
ctx, ok := lookupSession(params.SessionId)
|
||||
if !ok {
|
||||
log.Info("No active SSH session found for session ID: %s", params.SessionId)
|
||||
return
|
||||
}
|
||||
|
||||
err := ctx.Session.WindowChange(params.Height, params.Width)
|
||||
if err != nil {
|
||||
log.Error("Failed to resize SSH session for (%s): %s", params.SessionId, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("Resized SSH session for %s to %dx%d", params.SessionId, params.Width, params.Height)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
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 } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { decode } from 'cborg';
|
||||
import type { SSHFrameData } from '~/server/agent/dispatcher';
|
||||
import { decode, encode } from 'cborg';
|
||||
import type {
|
||||
SSHDataCommand,
|
||||
SSHFrameData,
|
||||
SSHResizeCommand,
|
||||
} from '~/server/agent/dispatcher';
|
||||
import cn from '~/utils/cn';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
|
||||
interface XTermProps {
|
||||
@@ -19,19 +29,40 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const term = useRef<xterm.Terminal>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
pause();
|
||||
|
||||
const terminal = new xterm.Terminal({
|
||||
allowProposedApi: true,
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontSize: 14,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.loadAddon(new ClipboardAddon());
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
terminal.unicode.activeVersion = '11';
|
||||
|
||||
const gl = new WebglAddon();
|
||||
terminal.loadAddon(gl);
|
||||
|
||||
const fit = new FitAddon();
|
||||
terminal.loadAddon(fit);
|
||||
|
||||
gl.onContextLoss(() => {
|
||||
console.warn('WebGL context lost, falling back to canvas rendering');
|
||||
gl.dispose();
|
||||
});
|
||||
|
||||
terminal.open(container.current!);
|
||||
terminal.focus();
|
||||
term.current = terminal;
|
||||
@@ -63,7 +94,15 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
|
||||
terminal.onData((input) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(input);
|
||||
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');
|
||||
}
|
||||
@@ -80,12 +119,57 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
ro.observe(container.current!);
|
||||
return () => {
|
||||
ws.removeEventListener('message', onMessage);
|
||||
term.current?.dispose();
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [ws, queue]);
|
||||
|
||||
return <div ref={container} style={{ height: '100%', width: '100%' }} />;
|
||||
return (
|
||||
<div className="relative w-full h-full group">
|
||||
<div ref={container} className="w-full h-full" />
|
||||
|
||||
{term.current && isResizing ? (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
|
||||
)}
|
||||
>
|
||||
{term.current.cols}x{term.current.rows}
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface Command {
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
interface SSHConnectCommand extends Command {
|
||||
export interface SSHConnectCommand extends Command {
|
||||
op: 'ssh_conn';
|
||||
payload: {
|
||||
sessionId: string;
|
||||
@@ -18,14 +18,31 @@ interface SSHConnectCommand extends Command {
|
||||
};
|
||||
}
|
||||
|
||||
interface SSHCloseCommand extends Command {
|
||||
export interface SSHCloseCommand extends Command {
|
||||
op: 'ssh_term';
|
||||
payload: {
|
||||
sessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
type AgentCommand = SSHConnectCommand | SSHCloseCommand;
|
||||
export interface SSHResizeCommand extends Command {
|
||||
op: 'ssh_resize';
|
||||
payload: {
|
||||
sessionId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SSHDataCommand extends Command {
|
||||
op: 'ssh_data';
|
||||
payload: {
|
||||
sessionId: string;
|
||||
data: Uint8Array;
|
||||
};
|
||||
}
|
||||
|
||||
type AgentCommand = SSHConnectCommand | SSHCloseCommand | SSHResizeCommand;
|
||||
|
||||
export async function dispatchCommand(
|
||||
dispatcher: Writable,
|
||||
|
||||
@@ -13,7 +13,7 @@ export type ChannelType = 0 | 1 | 2;
|
||||
interface SSHFrame {
|
||||
sessionId: string;
|
||||
channel: ChannelType;
|
||||
payload: Blob | ArrayBufferLike | string;
|
||||
payload: Buffer;
|
||||
}
|
||||
|
||||
export async function encodeSSHFrame(frame: SSHFrame) {
|
||||
@@ -23,17 +23,9 @@ export async function encodeSSHFrame(frame: SSHFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = Buffer.isBuffer(frame.payload)
|
||||
? frame.payload
|
||||
: typeof frame.payload === 'string'
|
||||
? Buffer.from(frame.payload, 'utf8')
|
||||
: frame.payload instanceof Blob
|
||||
? Buffer.from(await frame.payload.arrayBuffer())
|
||||
: Buffer.from(frame.payload);
|
||||
|
||||
// Size can only hold 4 bytes
|
||||
if (payload.length > 0xffffffff) {
|
||||
log.error('agent', 'SSH payload too large: %d bytes', payload.length);
|
||||
if (frame.payload.length > 0xffffffff) {
|
||||
log.error('agent', 'SSH payload too large: %d bytes', frame.payload.length);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,7 +34,7 @@ export async function encodeSSHFrame(frame: SSHFrame) {
|
||||
1 + // Version
|
||||
1 + // Channel Type
|
||||
(1 + sid.length) + // Session ID length + SID
|
||||
(4 + payload.length); // Payload length + Payload
|
||||
(4 + frame.payload.length); // Payload length + Payload
|
||||
|
||||
const buf = Buffer.alloc(frameSize);
|
||||
buf.write(MAGIC, 0, 'utf8');
|
||||
@@ -53,8 +45,8 @@ export async function encodeSSHFrame(frame: SSHFrame) {
|
||||
const offset = 7 + sid.length;
|
||||
sid.copy(buf, 7);
|
||||
|
||||
buf.writeUInt32BE(payload.length, offset);
|
||||
payload.copy(buf, offset + 4);
|
||||
buf.writeUInt32BE(frame.payload.length, offset);
|
||||
frame.payload.copy(buf, offset + 4);
|
||||
return buf;
|
||||
}
|
||||
|
||||
|
||||
+49
-7
@@ -1,10 +1,17 @@
|
||||
import { ChildProcess } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
import { decode } from 'cborg';
|
||||
import { Context } from 'hono';
|
||||
import { WSContext, WSEvents } from 'hono/ws';
|
||||
import log from '~/utils/log';
|
||||
import { dispatchCommand, dispatchWeb } from './dispatcher';
|
||||
import {
|
||||
Command,
|
||||
SSHDataCommand,
|
||||
SSHResizeCommand,
|
||||
dispatchCommand,
|
||||
dispatchWeb,
|
||||
} from './dispatcher';
|
||||
import { decodeSSHFrame, encodeSSHFrame } from './encoder';
|
||||
|
||||
interface SSHConnection {
|
||||
@@ -134,13 +141,48 @@ export class SSHMultiplexer {
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedFrame = await encodeSSHFrame({
|
||||
sessionId,
|
||||
channel: 0, // stdin
|
||||
payload: event.data,
|
||||
});
|
||||
const wsData = Buffer.isBuffer(event.data)
|
||||
? event.data
|
||||
: typeof event.data === 'string'
|
||||
? Buffer.from(event.data, 'utf8')
|
||||
: event.data instanceof Blob
|
||||
? Buffer.from(await event.data.arrayBuffer())
|
||||
: Buffer.from(event.data);
|
||||
|
||||
this.sshInput.write(encodedFrame);
|
||||
const obj = decode(wsData) as Command;
|
||||
if (obj.op === 'ssh_data') {
|
||||
const data = obj as SSHDataCommand;
|
||||
if (data.payload.sessionId !== sessionId) {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Received data for mismatched SSH session %s',
|
||||
data.payload.sessionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedFrame = await encodeSSHFrame({
|
||||
sessionId,
|
||||
channel: 0, // stdin
|
||||
payload: Buffer.from(data.payload.data),
|
||||
});
|
||||
|
||||
this.sshInput.write(encodedFrame);
|
||||
}
|
||||
|
||||
if (obj.op === 'ssh_resize') {
|
||||
const resize = obj as SSHResizeCommand;
|
||||
if (resize.payload.sessionId !== sessionId) {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Received resize for mismatched SSH session %s',
|
||||
resize.payload.sessionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await dispatchCommand(this.control, resize);
|
||||
}
|
||||
},
|
||||
|
||||
onClose: async (_, ws) => {
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
"@uiw/codemirror-theme-github": "^4.23.12",
|
||||
"@uiw/codemirror-theme-xcode": "^4.23.12",
|
||||
"@uiw/react-codemirror": "^4.23.12",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"arktype": "^2.1.20",
|
||||
"cborg": "^4.2.11",
|
||||
|
||||
Generated
+66
@@ -61,6 +61,21 @@ 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/addon-clipboard':
|
||||
specifier: ^0.1.0
|
||||
version: 0.1.0(@xterm/xterm@5.5.0)
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: ^0.8.0
|
||||
version: 0.8.0(@xterm/xterm@5.5.0)
|
||||
'@xterm/addon-web-links':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(@xterm/xterm@5.5.0)
|
||||
'@xterm/addon-webgl':
|
||||
specifier: ^0.18.0
|
||||
version: 0.18.0(@xterm/xterm@5.5.0)
|
||||
'@xterm/xterm':
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
@@ -1594,6 +1609,31 @@ packages:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@xterm/addon-clipboard@0.1.0':
|
||||
resolution: {integrity: sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^5.4.0
|
||||
|
||||
'@xterm/addon-fit@0.10.0':
|
||||
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^5.0.0
|
||||
|
||||
'@xterm/addon-unicode11@0.8.0':
|
||||
resolution: {integrity: sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^5.0.0
|
||||
|
||||
'@xterm/addon-web-links@0.11.0':
|
||||
resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^5.0.0
|
||||
|
||||
'@xterm/addon-webgl@0.18.0':
|
||||
resolution: {integrity: sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^5.0.0
|
||||
|
||||
'@xterm/xterm@5.5.0':
|
||||
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
|
||||
|
||||
@@ -1989,6 +2029,9 @@ packages:
|
||||
jose@6.0.11:
|
||||
resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==}
|
||||
|
||||
js-base64@3.7.7:
|
||||
resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -4719,6 +4762,27 @@ snapshots:
|
||||
- '@codemirror/lint'
|
||||
- '@codemirror/search'
|
||||
|
||||
'@xterm/addon-clipboard@0.1.0(@xterm/xterm@5.5.0)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.5.0
|
||||
js-base64: 3.7.7
|
||||
|
||||
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.5.0
|
||||
|
||||
'@xterm/addon-unicode11@0.8.0(@xterm/xterm@5.5.0)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.5.0
|
||||
|
||||
'@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.5.0
|
||||
|
||||
'@xterm/addon-webgl@0.18.0(@xterm/xterm@5.5.0)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.5.0
|
||||
|
||||
'@xterm/xterm@5.5.0': {}
|
||||
|
||||
acorn@8.14.1:
|
||||
@@ -5093,6 +5157,8 @@ snapshots:
|
||||
|
||||
jose@6.0.11: {}
|
||||
|
||||
js-base64@3.7.7: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.0:
|
||||
|
||||
Reference in New Issue
Block a user