diff --git a/agent/internal/hpagent/handler.go b/agent/internal/hpagent/handler.go index 0b54785..61667d6 100644 --- a/agent/internal/hpagent/handler.go +++ b/agent/internal/hpagent/handler.go @@ -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 } } diff --git a/agent/internal/sshutil/connect.go b/agent/internal/sshutil/connect.go index 6ac8c2d..c4e23cf 100644 --- a/agent/internal/sshutil/connect.go +++ b/agent/internal/sshutil/connect.go @@ -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) +} diff --git a/app/routes/ssh/xterm.client.tsx b/app/routes/ssh/xterm.client.tsx index 9e2f471..1fb8d99 100644 --- a/app/routes/ssh/xterm.client.tsx +++ b/app/routes/ssh/xterm.client.tsx @@ -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(null); const term = useRef(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
; + return ( +
+
+ + {term.current && isResizing ? ( +
+ {term.current.cols}x{term.current.rows} +
+ ) : undefined} +
+ ); } diff --git a/app/server/agent/dispatcher.ts b/app/server/agent/dispatcher.ts index e9e52cd..87585c3 100644 --- a/app/server/agent/dispatcher.ts +++ b/app/server/agent/dispatcher.ts @@ -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, diff --git a/app/server/agent/encoder.ts b/app/server/agent/encoder.ts index ab7e43b..73ac667 100644 --- a/app/server/agent/encoder.ts +++ b/app/server/agent/encoder.ts @@ -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; } diff --git a/app/server/agent/ssh.ts b/app/server/agent/ssh.ts index e6bdc08..9777232 100644 --- a/app/server/agent/ssh.ts +++ b/app/server/agent/ssh.ts @@ -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) => { diff --git a/package.json b/package.json index 52ab534..a706153 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7a9b14..629e533 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: