diff --git a/README.md b/README.md index e8483fa..71a6b56 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - Cloudflare Workers 原生部署,使用 Durable Objects 隔离每个 SSH 会话。 - 基于 xterm.js 的响应式终端,支持桌面端和移动端、自动缩放、全屏和会话日志。 - 内置 SFTP 文件管理,可浏览目录、上传/下载文件、新建与删除空目录、删除文件和重命名。 +- 内置实时进程管理,通过独立 WebSocket 和 SSH exec channel 展示 `top` 进程、CPU、负载、内存与 Swap 使用率。 - 支持 SSH password 与单密码提示的 keyboard-interactive 认证,以及 Ed25519、RSA、ECDSA 的未加密 OpenSSH 私钥认证。 - 首次连接时暂停认证并显示主机 SHA-256 指纹,确认后才会发送 SSH 凭据。 - SSH 连接成功后自动写入浏览器本地"历史记录",密码使用 AES-256-GCM 加密,私钥不保存。 @@ -154,6 +155,7 @@ npm run dev:web # 访问 http://localhost:5173,Vite 代理 /api 到 8787 | `/api/session` | `POST` | 匿名创建一次性会话票据 | | `/api/ssh?ticket=...&session=...` | `GET` + WebSocket Upgrade | 进入对应 Durable Object 并建立 SSH 会话 | | `/api/sftp?session=...&token=...` | `GET` + WebSocket Upgrade | 使用一次性附着令牌进入当前会话的文件通道 | +| `/api/processes?session=...&token=...` | `GET` + WebSocket Upgrade | 使用一次性附着令牌进入当前会话的进程监控通道 | 所有 `/api/*` 响应均允许跨站访问,并支持浏览器 `OPTIONS` 预检请求。公网访问控制应由 Cloudflare Access、WAF 和限流策略提供。 diff --git a/frontend/index.html b/frontend/index.html index cfc5ecf..6c2e7b6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -263,6 +263,32 @@ 选择目标并连接 +
+
+
+ CPU + -- + -- / -- / -- +
+ +
+
+
+ 内存 + -- + --/-- +
+ +
+
+
+ Swap + -- + --/-- +
+ +
+
延迟--
时长00:00
@@ -329,6 +355,22 @@ 文件管理 +
+ diff --git a/frontend/src/main.ts b/frontend/src/main.ts index ec759e5..61208eb 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -7,6 +7,7 @@ import { decryptPasswordResult, encryptPassword, isEncryptedPassword } from './p import { resolveConnectionControl, resolveConnectionPanel } from './ui-state'; import { classifyHostKey, SSH_FINGERPRINT_RE, type HostKeyPrompt } from './host-key'; import { FileManager, collectFileManagerElements } from './file-manager'; +import { ProcessManager, collectProcessManagerElements } from './process-manager'; import { resetTerminalForConnection } from './terminal-session'; import './style.css'; @@ -292,6 +293,8 @@ const ui = { eventMessage: element('event-message'), fileManagerTab: element('file-manager-tab'), fileManagerPanel: element('file-manager-panel'), + processManagerTab: element('process-manager-tab'), + processManagerPanel: element('process-manager-panel'), eventToggle: element('event-toggle'), eventLog: element('event-log'), toastRegion: element('toast-region'), @@ -359,6 +362,7 @@ function applyLanguage(language: Language, persist = false): void { } } if (fileManager) fileManager.setLanguage(); + if (processManager) processManager.setLanguage(); if (persist) { try { localStorage.setItem(LANGUAGE_STORAGE_KEY, language); } catch { /* Language still applies for this page. */ } @@ -398,6 +402,7 @@ const latestHistoryMutation = new Map(); const panelMedia = window.matchMedia('(max-width: 760px)'); let panelOpen = !panelMedia.matches; let fileManager: FileManager; +let processManager: ProcessManager; const terminal = new Terminal({ allowProposedApi: false, @@ -421,6 +426,11 @@ fileManager = new FileManager({ getLanguage: () => currentLanguage, onError: (message) => event(message, 'sftp', true), }); +processManager = new ProcessManager({ + elements: collectProcessManagerElements(), + getLanguage: () => currentLanguage, + onError: (message) => event(message, 'process', true), +}); function terminalTheme(): Record { return { @@ -1211,24 +1221,29 @@ function sendHostKeyDecision(accept: boolean): void { : bilingual('主机密钥已拒绝。', 'Host key rejected.'), 'host-key', !accept); } -type WorkspaceTab = 'files' | 'log'; +type WorkspaceTab = 'files' | 'processes' | 'log'; let activeWorkspaceTab: WorkspaceTab | null = null; function setWorkspaceTab(tab: WorkspaceTab | null, focus = false, rovingTab = tab ?? activeWorkspaceTab ?? 'files'): void { const filesActive = tab === 'files'; + const processesActive = tab === 'processes'; const logActive = tab === 'log'; activeWorkspaceTab = tab; ui.terminalCard.classList.toggle('workspace-panel-open', tab !== null); ui.fileManagerPanel.hidden = !filesActive; + ui.processManagerPanel.hidden = !processesActive; ui.eventLog.hidden = !logActive; ui.fileManagerTab.setAttribute('aria-selected', String(filesActive)); + ui.processManagerTab.setAttribute('aria-selected', String(processesActive)); ui.eventToggle.setAttribute('aria-selected', String(logActive)); ui.fileManagerTab.setAttribute('aria-expanded', String(filesActive)); + ui.processManagerTab.setAttribute('aria-expanded', String(processesActive)); ui.eventToggle.setAttribute('aria-expanded', String(logActive)); ui.fileManagerTab.tabIndex = rovingTab === 'files' ? 0 : -1; + ui.processManagerTab.tabIndex = rovingTab === 'processes' ? 0 : -1; ui.eventToggle.tabIndex = rovingTab === 'log' ? 0 : -1; if (logActive) requestAnimationFrame(() => { ui.eventLog.scrollTop = ui.eventLog.scrollHeight; }); - if (focus) (rovingTab === 'files' ? ui.fileManagerTab : ui.eventToggle).focus(); + if (focus) ({ files: ui.fileManagerTab, processes: ui.processManagerTab, log: ui.eventToggle })[rovingTab].focus(); requestAnimationFrame(() => fitTerminal(true)); } @@ -1239,8 +1254,12 @@ function toggleWorkspaceTab(tab: WorkspaceTab): void { function handleWorkspaceTabKey(event: KeyboardEvent): void { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); - const files = event.key === 'Home' || (event.key !== 'End' && event.currentTarget === ui.eventToggle); - setWorkspaceTab(files ? 'files' : 'log', true); + const tabs: WorkspaceTab[] = ['files', 'processes', 'log']; + const current = tabs.findIndex((tab) => ({ files: ui.fileManagerTab, processes: ui.processManagerTab, log: ui.eventToggle })[tab] === event.currentTarget); + const next = event.key === 'Home' ? 0 + : event.key === 'End' ? tabs.length - 1 + : (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length; + setWorkspaceTab(tabs[next], true); } function configureHostKeyDialog(hostKey: HostKeyPrompt): void { @@ -1295,6 +1314,20 @@ function handleServerMessage(message: ServerMessage): void { event(bilingual('文件管理通道已可用。', 'File management channel is available.'), 'sftp'); return; } + if (type === 'process_attach') { + if (typeof message.url !== 'string' || !message.url.startsWith('/api/processes?')) { + event(bilingual('收到无效的进程监控连接信息。', 'Received invalid process-monitor connection details.'), 'protocol', true); + return; + } + try { + processManager.attach(message.url); + } catch { + event(bilingual('无法打开进程监控连接。', 'Could not open the process-monitor connection.'), 'process', true); + return; + } + event(bilingual('进程监控通道已可用。', 'Process monitor channel is available.'), 'process'); + return; + } if (type === 'pong') { if (lastPingAt > 0) ui.metricRtt.textContent = `${Math.max(1, Math.round(performance.now() - lastPingAt))} ms`; return; @@ -1415,6 +1448,7 @@ function failActiveConnection(activeSocket: WebSocket | null, closeReason: strin currentRememberedFingerprint = ''; stopTimers(); fileManager.reset(); + processManager.reset(); clearHostKeyPrompt(); invalidateHistoryPasswordLoad(); currentSessionSubtitle = displayReason; @@ -1557,6 +1591,7 @@ async function connect(): Promise { currentRememberedFingerprint = ''; stopTimers(); fileManager.reset(); + processManager.reset(); clearHostKeyPrompt(); invalidateHistoryPasswordLoad(); const wasActive = connectionState === 'connected'; @@ -1600,6 +1635,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected socket = null; stopTimers(); fileManager.reset(); + processManager.reset(); clearHostKeyPrompt(); invalidateHistoryPasswordLoad(); currentExpectedFingerprint = ''; @@ -1858,8 +1894,10 @@ ui.fullscreenTerminal.addEventListener('click', async () => { }); document.addEventListener('fullscreenchange', () => fitTerminal(true)); ui.fileManagerTab.addEventListener('click', () => toggleWorkspaceTab('files')); +ui.processManagerTab.addEventListener('click', () => toggleWorkspaceTab('processes')); ui.eventToggle.addEventListener('click', () => toggleWorkspaceTab('log')); ui.fileManagerTab.addEventListener('keydown', handleWorkspaceTabKey); +ui.processManagerTab.addEventListener('keydown', handleWorkspaceTabKey); ui.eventToggle.addEventListener('keydown', handleWorkspaceTabKey); ui.languageToggle.addEventListener('click', () => { applyLanguage(currentLanguage === 'zh-CN' ? 'en' : 'zh-CN', true); @@ -1884,6 +1922,7 @@ terminal.onData(sendTerminalData); new ResizeObserver(() => fitTerminal(true)).observe(ui.terminalStage); window.addEventListener('beforeunload', () => { fileManager.reset(); + processManager.reset(); socket?.close(1000, 'Page closed'); }); document.addEventListener('keydown', (keyEvent) => { diff --git a/frontend/src/process-manager.ts b/frontend/src/process-manager.ts new file mode 100644 index 0000000..122add3 --- /dev/null +++ b/frontend/src/process-manager.ts @@ -0,0 +1,441 @@ +export interface ProcessManagerElements { + panel: HTMLElement; + tableBody: HTMLTableSectionElement; + status: HTMLElement; + empty: HTMLElement; + error: HTMLElement; + updated: HTMLElement; + cpuValue: HTMLElement; + cpuProgress: HTMLProgressElement; + cpuLoad: HTMLElement; + memoryValue: HTMLElement; + memoryAmount: HTMLElement; + memoryProgress: HTMLProgressElement; + swapValue: HTMLElement; + swapAmount: HTMLElement; + swapProgress: HTMLProgressElement; +} + +interface ResourceUsage { + usedBytes: number; + totalBytes: number; + percent: number; +} + +interface ProcessEntry { + pid: number; + user: string; + memoryBytes: number | null; + memoryPercent: number | null; + cpuPercent: number | null; + state: string; + time: string; + command: string; +} + +type ProcessSortKey = 'pid' | 'user' | 'memoryBytes' | 'memoryPercent' | 'cpuPercent' | 'state' | 'time'; +type SortDirection = 'ascending' | 'descending'; + +interface ProcessSnapshot { + type: 'process_snapshot'; + metrics: { + cpuPercent: number | null; + loadAverage: [number, number, number] | null; + memory: ResourceUsage | null; + swap: ResourceUsage | null; + }; + processes: ProcessEntry[]; + timestamp: number; +} + +interface ProcessManagerOptions { + elements: ProcessManagerElements; + getLanguage: () => 'zh-CN' | 'en'; + onError: (message: string) => void; +} + +const MAX_PROCESSES = 512; + +function finitePercent(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 10_000; +} + +function isUsage(value: unknown): value is ResourceUsage { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const usage = value as Partial; + return typeof usage.usedBytes === 'number' && Number.isFinite(usage.usedBytes) && usage.usedBytes >= 0 + && typeof usage.totalBytes === 'number' && Number.isFinite(usage.totalBytes) && usage.totalBytes >= usage.usedBytes + && finitePercent(usage.percent) && usage.percent <= 100; +} + +function isProcess(value: unknown): value is ProcessEntry { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const process = value as Partial; + return typeof process.pid === 'number' && Number.isSafeInteger(process.pid) && process.pid > 0 + && typeof process.user === 'string' && process.user.length <= 256 + && (process.memoryBytes === null || (typeof process.memoryBytes === 'number' && Number.isFinite(process.memoryBytes) && process.memoryBytes >= 0)) + && (process.memoryPercent === null || finitePercent(process.memoryPercent)) + && (process.cpuPercent === null || finitePercent(process.cpuPercent)) + && typeof process.state === 'string' && process.state.length <= 32 + && typeof process.time === 'string' && process.time.length <= 64 + && typeof process.command === 'string' && process.command.length <= 4096; +} + +function isSnapshot(value: unknown): value is ProcessSnapshot { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const snapshot = value as Partial; + const metrics = snapshot.metrics; + return snapshot.type === 'process_snapshot' + && typeof snapshot.timestamp === 'number' && Number.isFinite(snapshot.timestamp) + && Array.isArray(snapshot.processes) && snapshot.processes.length <= MAX_PROCESSES && snapshot.processes.every(isProcess) + && Boolean(metrics) && typeof metrics === 'object' + && (metrics!.cpuPercent === null || finitePercent(metrics!.cpuPercent)) + && (metrics!.loadAverage === null || (Array.isArray(metrics!.loadAverage) && metrics!.loadAverage.length === 3 && metrics!.loadAverage.every(finitePercent))) + && (metrics!.memory === null || isUsage(metrics!.memory)) + && (metrics!.swap === null || isUsage(metrics!.swap)); +} + +function formatPercent(value: number | null): string { + if (value === null) return '--'; + return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)}%`; +} + +function formatBytes(bytes: number | null): string { + if (bytes === null) return '--'; + const units = ['B', 'K', 'M', 'G', 'T', 'P']; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + const precision = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(precision)}${units[unit]}`; +} + +function usageColor(percent: number): string { + const value = Math.min(100, Math.max(0, percent)); + const stops = [ + { value: 0, hue: 150 }, + { value: 50, hue: 125 }, + { value: 65, hue: 55 }, + { value: 80, hue: 30 }, + { value: 100, hue: 0 }, + ]; + const upperIndex = stops.findIndex((stop) => value <= stop.value); + if (upperIndex <= 0) return `hsl(${stops[0].hue} 82% 58%)`; + const lower = stops[upperIndex - 1]; + const upper = stops[upperIndex]; + const position = (value - lower.value) / (upper.value - lower.value); + const hue = lower.hue + ((upper.hue - lower.hue) * position); + return `hsl(${hue.toFixed(1)} 82% 58%)`; +} + +export class ProcessManager { + private readonly elements: ProcessManagerElements; + private readonly getLanguage: () => 'zh-CN' | 'en'; + private readonly onError: (message: string) => void; + private socket: WebSocket | null = null; + private generation = 0; + private snapshot: ProcessSnapshot | null = null; + private sortKey: ProcessSortKey = 'cpuPercent'; + private sortDirection: SortDirection = 'descending'; + private readonly progressAnimations = new Map(); + + constructor(options: ProcessManagerOptions) { + this.elements = options.elements; + this.getLanguage = options.getLanguage; + this.onError = options.onError; + this.elements.panel.addEventListener('click', (event) => { + const button = (event.target as HTMLElement).closest('[data-process-sort]'); + if (!button) return; + this.changeSort(button.dataset.processSort as ProcessSortKey); + }); + this.render(); + } + + attach(url: string): void { + this.resetSocket(); + const target = new URL(url, window.location.href); + if (target.origin !== window.location.origin || target.pathname !== '/api/processes') { + throw new Error('Process WebSocket must use the current origin'); + } + target.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const generation = ++this.generation; + const socket = new WebSocket(target); + this.socket = socket; + this.setStatus('正在启动进程监控…', 'Starting process monitor…'); + socket.addEventListener('open', () => { + if (!this.isCurrent(socket, generation)) return; + socket.send(JSON.stringify({ type: 'process_start' })); + }); + socket.addEventListener('message', (event) => { + if (!this.isCurrent(socket, generation) || typeof event.data !== 'string') return; + this.handleMessage(event.data); + }); + socket.addEventListener('error', () => { + if (!this.isCurrent(socket, generation)) return; + this.showError('进程监控连接错误。', 'Process monitor connection error.'); + }); + socket.addEventListener('close', (event) => { + if (!this.isCurrent(socket, generation)) return; + this.socket = null; + if (event.code !== 1000 && event.code !== 1005) { + this.showError('进程监控已意外停止。', 'Process monitor stopped unexpectedly.'); + } + }); + } + + reset(): void { + this.resetSocket(); + this.snapshot = null; + this.render(); + } + + setLanguage(): void { + this.updateSortHeaders(); + this.render(); + } + + private changeSort(key: ProcessSortKey): void { + if (this.sortKey === key) { + this.sortDirection = this.sortDirection === 'ascending' ? 'descending' : 'ascending'; + } else { + this.sortKey = key; + this.sortDirection = key === 'user' || key === 'state' ? 'ascending' : 'descending'; + } + this.updateSortHeaders(); + this.render(); + } + + private updateSortHeaders(): void { + for (const button of this.elements.panel.querySelectorAll('[data-process-sort]')) { + const key = button.dataset.processSort as ProcessSortKey; + const header = button.closest('th'); + const active = key === this.sortKey; + header?.setAttribute('aria-sort', active ? this.sortDirection : 'none'); + const label = button.dataset[this.getLanguage() === 'zh-CN' ? 'i18nZh' : 'i18nEn'] ?? button.textContent ?? ''; + button.setAttribute('aria-label', active + ? this.getLanguage() === 'zh-CN' + ? `${label},当前${this.sortDirection === 'ascending' ? '升序' : '降序'},点击切换排序` + : `${label}, currently ${this.sortDirection}; activate to reverse` + : this.getLanguage() === 'zh-CN' + ? `按 ${label} 排序` + : `Sort by ${label}`); + } + } + + private sortedProcesses(processes: ProcessEntry[]): ProcessEntry[] { + const key = this.sortKey; + const direction = this.sortDirection === 'ascending' ? 1 : -1; + const value = (process: ProcessEntry): string | number | null => { + if (key === 'time') return this.processTimeSeconds(process.time); + return process[key]; + }; + return processes.map((process, index) => ({ process, index })).sort((left, right) => { + const a = value(left.process); + const b = value(right.process); + if (a === null && b === null) return left.index - right.index; + if (a === null) return 1; + if (b === null) return -1; + const compared = typeof a === 'number' && typeof b === 'number' + ? a - b + : String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); + return compared === 0 ? left.index - right.index : compared * direction; + }).map(({ process }) => process); + } + + private processTimeSeconds(value: string): number | null { + const parts = value.replace(',', '.').split(':').map(Number); + if (parts.some((part) => !Number.isFinite(part))) return null; + if (parts.length === 3) return (parts[0] * 3600) + (parts[1] * 60) + parts[2]; + if (parts.length === 2) return (parts[0] * 60) + parts[1]; + return parts.length === 1 ? parts[0] : null; + } + + private handleMessage(serialized: string): void { + let message: unknown; + try { message = JSON.parse(serialized); } catch { return; } + if (isSnapshot(message)) { + this.snapshot = message; + this.render(); + return; + } + if (!message || typeof message !== 'object' || Array.isArray(message)) return; + const value = message as Record; + if (value.type === 'process_ready') { + this.setStatus('正在等待首个 top 快照…', 'Waiting for the first top snapshot…'); + } else if (value.type === 'process_error' && typeof value.message === 'string') { + this.showError(value.message, value.message); + } + } + + private render(): void { + const snapshot = this.snapshot; + if (!snapshot) { + this.elements.tableBody.replaceChildren(); + this.elements.empty.hidden = true; + this.elements.error.hidden = true; + this.elements.updated.textContent = '--'; + this.resetMetric(this.elements.cpuValue, this.elements.cpuProgress); + this.elements.cpuLoad.textContent = '-- / -- / --'; + this.resetUsage(this.elements.memoryValue, this.elements.memoryAmount, this.elements.memoryProgress); + this.resetUsage(this.elements.swapValue, this.elements.swapAmount, this.elements.swapProgress); + this.setStatus('连接 SSH 后即可查看实时进程', 'Connect to SSH to view live processes'); + return; + } + + const fragment = document.createDocumentFragment(); + for (const process of this.sortedProcesses(snapshot.processes)) { + const row = document.createElement('tr'); + const values = [ + String(process.pid), + process.user || '--', + formatBytes(process.memoryBytes), + formatPercent(process.memoryPercent), + formatPercent(process.cpuPercent), + process.state || '--', + process.time || '--', + process.command || '--', + ]; + values.forEach((value, index) => { + const cell = document.createElement('td'); + cell.textContent = value; + if (index === values.length - 1) cell.title = value; + row.append(cell); + }); + fragment.append(row); + } + this.elements.tableBody.replaceChildren(fragment); + this.elements.empty.hidden = snapshot.processes.length !== 0; + this.elements.error.hidden = true; + const updated = new Date(snapshot.timestamp).toLocaleTimeString([], { hour12: false }); + this.elements.updated.textContent = updated; + this.setStatus( + `共 ${snapshot.processes.length} 个进程 · 更新于 ${updated}`, + `${snapshot.processes.length} processes · Updated ${updated}`, + ); + this.setMetric(this.elements.cpuValue, this.elements.cpuProgress, snapshot.metrics.cpuPercent); + this.elements.cpuLoad.textContent = snapshot.metrics.loadAverage?.map((value) => value.toFixed(2)).join(' / ') ?? '-- / -- / --'; + this.setUsage(this.elements.memoryValue, this.elements.memoryAmount, this.elements.memoryProgress, snapshot.metrics.memory); + this.setUsage(this.elements.swapValue, this.elements.swapAmount, this.elements.swapProgress, snapshot.metrics.swap); + } + + private setMetric(valueElement: HTMLElement, progress: HTMLProgressElement, value: number | null): void { + valueElement.textContent = formatPercent(value); + if (value === null) { + this.resetProgress(progress); + valueElement.style.removeProperty('--usage-color'); + progress.style.removeProperty('--usage-color'); + return; + } + this.animateProgress(progress, Math.min(100, value)); + const color = usageColor(value); + valueElement.style.setProperty('--usage-color', color); + progress.style.setProperty('--usage-color', color); + } + + private resetMetric(valueElement: HTMLElement, progress: HTMLProgressElement): void { + valueElement.textContent = '--'; + this.resetProgress(progress); + valueElement.style.removeProperty('--usage-color'); + progress.style.removeProperty('--usage-color'); + } + + private resetProgress(progress: HTMLProgressElement): void { + const animation = this.progressAnimations.get(progress); + if (animation !== undefined) cancelAnimationFrame(animation); + this.progressAnimations.delete(progress); + progress.value = 0; + } + + private animateProgress(progress: HTMLProgressElement, target: number): void { + const previousAnimation = this.progressAnimations.get(progress); + if (previousAnimation !== undefined) cancelAnimationFrame(previousAnimation); + const startValue = progress.value; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || Math.abs(target - startValue) < 0.1) { + progress.value = target; + this.progressAnimations.delete(progress); + return; + } + const startedAt = performance.now(); + const duration = 520; + const step = (now: number): void => { + const elapsed = Math.min(1, (now - startedAt) / duration); + const eased = 1 - ((1 - elapsed) ** 3); + progress.value = startValue + ((target - startValue) * eased); + if (elapsed < 1) { + this.progressAnimations.set(progress, requestAnimationFrame(step)); + } else { + this.progressAnimations.delete(progress); + } + }; + this.progressAnimations.set(progress, requestAnimationFrame(step)); + } + + private resetUsage(valueElement: HTMLElement, amountElement: HTMLElement, progress: HTMLProgressElement): void { + this.resetMetric(valueElement, progress); + amountElement.textContent = '--/--'; + } + + private setUsage(valueElement: HTMLElement, amountElement: HTMLElement, progress: HTMLProgressElement, usage: ResourceUsage | null): void { + if (!usage) { + this.resetUsage(valueElement, amountElement, progress); + return; + } + this.setMetric(valueElement, progress, usage.percent); + amountElement.textContent = `${formatBytes(usage.usedBytes)}/${formatBytes(usage.totalBytes)}`; + } + + private setStatus(zh: string, en: string): void { + this.elements.status.textContent = this.getLanguage() === 'zh-CN' ? zh : en; + } + + private showError(zh: string, en: string): void { + const message = this.getLanguage() === 'zh-CN' ? zh : en; + this.elements.error.textContent = message; + this.elements.error.hidden = false; + this.elements.empty.hidden = true; + this.setStatus(zh, en); + this.onError(message); + } + + private resetSocket(): void { + const socket = this.socket; + this.socket = null; + this.generation++; + if (!socket) return; + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'process_stop' })); + if (socket.readyState < WebSocket.CLOSING) socket.close(1000, 'Process monitor reset'); + } + + private isCurrent(socket: WebSocket, generation: number): boolean { + return this.socket === socket && this.generation === generation; + } +} + +function getElement(id: string): T { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing process manager element #${id}`); + return element as T; +} + +export function collectProcessManagerElements(): ProcessManagerElements { + return { + panel: getElement('process-manager-panel'), + tableBody: getElement('process-table-body'), + status: getElement('process-manager-status'), + empty: getElement('process-manager-empty'), + error: getElement('process-manager-error'), + updated: getElement('process-updated'), + cpuValue: getElement('resource-cpu-value'), + cpuProgress: getElement('resource-cpu-progress'), + cpuLoad: getElement('resource-cpu-load'), + memoryValue: getElement('resource-memory-value'), + memoryAmount: getElement('resource-memory-amount'), + memoryProgress: getElement('resource-memory-progress'), + swapValue: getElement('resource-swap-value'), + swapAmount: getElement('resource-swap-amount'), + swapProgress: getElement('resource-swap-progress'), + }; +} diff --git a/frontend/src/style.css b/frontend/src/style.css index 6e2721d..9528640 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -964,12 +964,95 @@ button:disabled { text-align: right; } +.resource-metrics { + display: flex; + min-width: 0; + flex: 0 1 490px; + align-items: center; + gap: 9px; +} + +.resource-metric { + min-width: 0; + flex: 1 1 0; + color: #a8b8c1; + font-family: var(--mono); + font-size: 10px; + white-space: nowrap; +} + +.resource-metric-line { + display: flex; + min-width: 0; + align-items: baseline; + gap: 6px; +} + +.resource-metric-line > span { + flex: 0 0 auto; + color: #8fa2ad; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.02em; +} + +.resource-metric-line > strong { + flex: 0 0 auto; + overflow: hidden; + color: #d4e0e5; + font-size: 10px; + font-weight: 750; + text-overflow: ellipsis; +} + +.resource-metric-line > .resource-percentage { + color: color-mix(in srgb, var(--usage-color, #d4e0e5) 88%, var(--text)); + font-weight: 900; + transition: color 220ms ease; +} + +.resource-metric-line > .resource-detail { + min-width: 0; + flex: 1 1 auto; + color: #9aadb7; + font-weight: 400; + text-align: right; +} + +.resource-metric progress { + width: 100%; + height: 4px; + display: block; + margin-top: 2px; + overflow: hidden; + border: 0; + border-radius: 99px; + accent-color: var(--usage-color, var(--accent)); +} + +.resource-metric progress::-webkit-progress-bar { + border-radius: 99px; + background: #263642; +} + +.resource-metric progress::-webkit-progress-value { + border-radius: 99px; + background: var(--usage-color, var(--accent)); + transition: background-color 320ms ease; +} + +.resource-metric progress::-moz-progress-bar { + border-radius: 99px; + background: var(--usage-color, var(--accent)); + transition: background-color 320ms ease; +} + .session-metrics span { margin-bottom: 2px; - color: #5e707d; - font-size: 7px; + color: #81939e; + font-size: 8px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.04em; } .session-metrics strong { @@ -1150,6 +1233,8 @@ button:disabled { } #event-message { + min-width: 0; + flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1187,7 +1272,8 @@ button:disabled { background: transparent; color: var(--muted); font-family: inherit; - font-size: inherit; + font-size: 10px; + font-weight: 650; } .dock-tab-icon { @@ -1253,6 +1339,143 @@ button:disabled { background: var(--surface); } +.process-manager { + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: minmax(0, 1fr) 25px; + overflow: hidden; + background: var(--surface); +} + +.process-table-wrap { + position: relative; + min-width: 0; + min-height: 0; + overflow: auto; + scrollbar-color: var(--border) transparent; + scrollbar-width: thin; +} + +.process-table { + width: 100%; + min-width: 820px; + border-spacing: 0; + table-layout: fixed; + color: var(--text); + font-family: var(--mono); + font-size: 10px; +} + +.process-table th { + position: sticky; + top: 0; + z-index: 1; + height: 25px; + padding: 0 8px; + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border); + background: var(--surface-2); + color: var(--muted); + font-family: var(--sans); + font-size: 9px; + text-align: right; + white-space: nowrap; +} + +.process-table th:first-child { width: 8%; } +.process-table th:nth-child(2) { width: 8%; } +.process-table th:nth-child(3) { width: 8%; } +.process-table th:nth-child(4), +.process-table th:nth-child(5) { width: 7%; } +.process-table th:nth-child(6) { width: 6%; } +.process-table th:nth-child(7) { width: 9%; } +.process-table th:nth-child(8) { width: 47%; text-align: left; } + +.process-sort { + position: relative; + width: 100%; + height: 25px; + padding: 0 11px 0 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: 700; + text-align: right; +} + +.process-sort::after { + position: absolute; + right: 0; + color: var(--muted-2); + content: '↕'; +} + +.process-table th[aria-sort='ascending'] .process-sort, +.process-table th[aria-sort='descending'] .process-sort { + color: var(--accent); +} + +.process-table th[aria-sort='ascending'] .process-sort::after { content: '↑'; } +.process-table th[aria-sort='descending'] .process-sort::after { content: '↓'; } + +.process-table td { + height: 24px; + padding: 0 8px; + overflow: hidden; + border-bottom: 1px solid var(--border-soft); + text-overflow: ellipsis; + white-space: nowrap; +} + +.process-table td:not(:last-child) { + text-align: right; +} + +.process-table td:last-child { + text-align: left; +} + +.process-table tbody tr:hover { + background: var(--surface-2); +} + +.process-manager-state { + position: absolute; + inset: 25px 0 0; + display: grid; + padding: 16px; + place-items: center; + background: color-mix(in srgb, var(--surface) 92%, transparent); + color: var(--muted); + font-size: 10px; +} + +.process-manager-state.error { + color: var(--danger); +} + +.process-statusbar { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 8px; + border-top: 1px solid var(--border-soft); + background: var(--panel); + color: var(--muted-2); + font-family: var(--mono); + font-size: 8px; + white-space: nowrap; +} + +#process-manager-status { + overflow: hidden; + text-overflow: ellipsis; +} + .file-toolbar { display: flex; min-width: 0; @@ -1747,6 +1970,35 @@ button:disabled { .session-metrics > div:first-child { display: none; } + +} + +@media (max-width: 1500px) { + .resource-metric progress { + display: none; + } +} + +@media (max-width: 1080px) { + .session-metrics { + display: none; + } + + .resource-metrics { + gap: 5px; + } + + .resource-metric { + flex-basis: 0; + } + + .resource-detail { + display: none; + } + + .terminal-actions .button-label { + display: none; + } } @media (max-width: 760px) { @@ -1802,13 +2054,38 @@ button:disabled { padding: 8px; } - .session-metrics > div { - display: none; + .terminal-toolbar { + gap: 6px; + padding-inline: 7px; } - .session-metrics > div:nth-child(2) { - display: block; - border-left: 0; + .session-target { + flex: 0 1 88px; + gap: 6px; + } + + .resource-metrics { + flex: 0 1 180px; + gap: 4px; + } + + .resource-metric { + flex: 1 1 0; + } + + .resource-metric-line { + justify-content: center; + gap: 3px; + } + + .terminal-actions { + flex: 0 0 auto; + gap: 3px; + margin-left: auto; + } + + .terminal-actions .toolbar-button { + padding-inline: 6px; } .terminal-card { @@ -1847,6 +2124,19 @@ button:disabled { min-width: 560px; } + .process-table { + min-width: 650px; + } + + .process-table th:nth-child(4), + .process-table td:nth-child(4), + .process-table th:nth-child(6), + .process-table td:nth-child(6), + .process-table th:nth-child(7), + .process-table td:nth-child(7) { + display: none; + } + .file-table [data-column='permissions'], .file-table [data-column='owner'], .file-table [data-column='group'], @@ -1867,7 +2157,51 @@ button:disabled { .dock-tab { min-width: 0; - padding: 0 13px; + gap: 4px; + padding: 0 7px; + font-size: 10px; + } + + .dock-tab .button-label { + display: inline; + white-space: nowrap; + } + + .dock-tab-icon { + width: 13px; + height: 13px; + } +} + +@media (max-width: 200px) { + .terminal-pane { + grid-template-rows: 70px minmax(0, 1fr); + } + + .terminal-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: 34px 28px; + gap: 0 8px; + padding-block: 3px; + } + + .resource-metrics { + width: 100%; + grid-column: 1 / -1; + justify-content: space-between; + border-top: 1px solid #1c2932; + } + + .resource-metric, + .resource-memory, + .resource-swap { + min-width: 0; + } + + .resource-metric-line > span, + .resource-metric-line > strong { + font-size: 8px; } } diff --git a/src/backend/durable-object.ts b/src/backend/durable-object.ts index 6271264..e61c73d 100644 --- a/src/backend/durable-object.ts +++ b/src/backend/durable-object.ts @@ -6,7 +6,8 @@ import { SSHSession } from './session'; interface MainAttachment { role: 'main'; phase: 'waiting' | 'connecting' | 'connected' } interface SFTPAttachment { role: 'sftp'; phase: 'connected' } -type Attachment = MainAttachment | SFTPAttachment; +interface ProcessAttachment { role: 'process'; phase: 'connected' } +type Attachment = MainAttachment | SFTPAttachment | ProcessAttachment; interface StoredTicket { secret: number[]; expiresAt: number; ip: string } interface PendingConnection { cancelled: boolean; @@ -20,6 +21,7 @@ interface SFTPAttachToken { timeout: ReturnType; session?: SSHSession; } +type AuxiliaryAttachToken = SFTPAttachToken; const TICKET_STORAGE_KEY = 'session-ticket'; const SFTP_ATTACH_TOKEN_TTL_MS = 10 * 60 * 1000; const SFTP_ATTACH_TOKEN_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -35,13 +37,18 @@ export class SSHSessionDO implements DurableObject { private readonly sftpSessions = new Map(); private readonly sftpOwners = new Map(); private readonly sftpWebSocketsByMain = new Map>(); + private readonly processAttachTokens = new Map(); + private readonly processTokenByMainWebSocket = new Map(); + private readonly processSessions = new Map(); + private readonly processOwners = new Map(); + private readonly processWebSocketsByMain = new Map>(); constructor(state: DurableObjectState, env: Env) { this.state = state; this.env = env; for (const ws of state.getWebSockets()) { const attachment = ws.deserializeAttachment() as Attachment | null; - if (attachment?.role === 'sftp') { + if (attachment?.role === 'sftp' || attachment?.role === 'process') { try { ws.close(1012, 'Worker session restarted; reconnect required'); } catch { /* already closed */ } } else if (attachment?.phase === 'connected') { try { ws.close(1012, 'Worker session restarted; reconnect required'); } catch { /* already closed */ } @@ -68,13 +75,18 @@ export class SSHSessionDO implements DurableObject { return stored ? Response.json(created) : Response.json({ error: 'Ticket already created' }, { status: 409 }); } if (url.pathname === '/sftp') return this.attachSFTP(request); + if (url.pathname === '/processes') return this.attachProcesses(request); if (url.pathname !== '/connect') return Response.json({ error: 'Not found' }, { status: 404 }); if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') return Response.json({ error: 'WebSocket upgrade required' }, { status: 426 }); const sftpAttachToken = request.headers.get('x-sftp-attach-token'); const sftpAttachUrl = request.headers.get('x-sftp-attach-url'); + const processAttachToken = request.headers.get('x-process-attach-token'); + const processAttachUrl = request.headers.get('x-process-attach-url'); if (!sftpAttachToken || !SFTP_ATTACH_TOKEN_PATTERN.test(sftpAttachToken) - || !sftpAttachUrl || !this.isValidSFTPAttachUrl(sftpAttachUrl, sftpAttachToken)) { - return Response.json({ error: 'Invalid SFTP attachment authorization' }, { status: 400 }); + || !sftpAttachUrl || !this.isValidAttachUrl(sftpAttachUrl, sftpAttachToken, '/api/sftp') + || !processAttachToken || !SFTP_ATTACH_TOKEN_PATTERN.test(processAttachToken) + || !processAttachUrl || !this.isValidAttachUrl(processAttachUrl, processAttachToken, '/api/processes')) { + return Response.json({ error: 'Invalid attachment authorization' }, { status: 400 }); } const ticket = request.headers.get('x-session-ticket'); const ip = request.headers.get('x-client-ip') ?? 'unknown'; @@ -87,12 +99,27 @@ export class SSHSessionDO implements DurableObject { this.state.acceptWebSocket(server); server.serializeAttachment({ role: 'main', phase: 'waiting' } satisfies MainAttachment); this.registerSFTPAttachToken(sftpAttachToken, sftpAttachUrl, server); + this.registerProcessAttachToken(processAttachToken, processAttachUrl, server); const deadline = setTimeout(() => this.reject(server, 'Connect message timeout'), 10_000); this.deadlines.set(server, deadline); return new Response(null, { status: 101, webSocket: client }); } async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + const processSession = this.processSessions.get(ws); + if (processSession || this.isProcessWebSocket(ws)) { + try { + if (!processSession || ws.readyState !== WebSocket.OPEN) { + this.cleanupProcessWebSocket(ws); + if (ws.readyState === WebSocket.OPEN) ws.close(1008, 'Process monitor is unavailable'); + return; + } + await processSession.handleProcessClientMessage(message); + } catch (error) { + try { ws.send(JSON.stringify({ type: 'process_error', message: error instanceof Error ? error.message : String(error) })); } catch { /* already closed */ } + } + return; + } const sftpSession = this.sftpSessions.get(ws); if (sftpSession || this.isSFTPWebSocket(ws)) { try { @@ -148,6 +175,12 @@ export class SSHSessionDO implements DurableObject { sftpAttach.session = ssh; ssh.setSFTPAttachUrl(sftpAttach.attachUrl); } + const processToken = this.processTokenByMainWebSocket.get(ws); + const processAttach = processToken ? this.processAttachTokens.get(processToken) : undefined; + if (processAttach) { + processAttach.session = ssh; + ssh.setProcessAttachUrl(processAttach.attachUrl); + } this.sessions.set(ws, ssh); this.pendingConnections.delete(ws); pending.socket = undefined; @@ -159,12 +192,14 @@ export class SSHSessionDO implements DurableObject { } async webSocketClose(ws: WebSocket): Promise { - if (this.isSFTPWebSocket(ws)) this.cleanupSFTPWebSocket(ws); + if (this.isProcessWebSocket(ws)) this.cleanupProcessWebSocket(ws); + else if (this.isSFTPWebSocket(ws)) this.cleanupSFTPWebSocket(ws); else this.cleanup(ws); } async webSocketError(ws: WebSocket): Promise { - if (this.isSFTPWebSocket(ws)) this.cleanupSFTPWebSocket(ws); + if (this.isProcessWebSocket(ws)) this.cleanupProcessWebSocket(ws); + else if (this.isSFTPWebSocket(ws)) this.cleanupSFTPWebSocket(ws); else this.cleanup(ws); } @@ -238,6 +273,36 @@ export class SSHSessionDO implements DurableObject { return new Response(null, { status: 101, webSocket: client }); } + private attachProcesses(request: Request): Response { + if (request.method !== 'GET') return Response.json({ error: 'Method not allowed' }, { status: 405 }); + if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') { + return Response.json({ error: 'WebSocket upgrade required' }, { status: 426 }); + } + const token = request.headers.get('x-process-attach-token'); + if (!token || !SFTP_ATTACH_TOKEN_PATTERN.test(token)) { + return Response.json({ error: 'Invalid process attachment token' }, { status: 401 }); + } + const attach = this.processAttachTokens.get(token); + if (!attach || attach.expiresAt < Date.now() || attach.mainWebSocket.readyState !== WebSocket.OPEN) { + if (attach) this.deleteProcessAttachToken(token, attach); + return Response.json({ error: 'Invalid or expired process attachment token' }, { status: 401 }); + } + if (!attach.session) return Response.json({ error: 'SSH session is still initializing' }, { status: 409 }); + this.deleteProcessAttachToken(token, attach); + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + this.state.acceptWebSocket(server); + server.serializeAttachment({ role: 'process', phase: 'connected' } satisfies ProcessAttachment); + this.processSessions.set(server, attach.session); + this.processOwners.set(server, attach.mainWebSocket); + const sockets = this.processWebSocketsByMain.get(attach.mainWebSocket) ?? new Set(); + sockets.add(server); + this.processWebSocketsByMain.set(attach.mainWebSocket, sockets); + attach.session.attachProcessWebSocket(server); + return new Response(null, { status: 101, webSocket: client }); + } + private registerSFTPAttachToken(token: string, attachUrl: string, mainWebSocket: WebSocket): void { const expiresAt = Date.now() + SFTP_ATTACH_TOKEN_TTL_MS; const attach: SFTPAttachToken = { @@ -250,6 +315,28 @@ export class SSHSessionDO implements DurableObject { this.sftpTokenByMainWebSocket.set(mainWebSocket, token); } + private registerProcessAttachToken(token: string, attachUrl: string, mainWebSocket: WebSocket): void { + const expiresAt = Date.now() + SFTP_ATTACH_TOKEN_TTL_MS; + const attach: AuxiliaryAttachToken = { + mainWebSocket, + attachUrl, + expiresAt, + timeout: setTimeout(() => this.deleteProcessAttachToken(token, attach), SFTP_ATTACH_TOKEN_TTL_MS), + }; + this.processAttachTokens.set(token, attach); + this.processTokenByMainWebSocket.set(mainWebSocket, token); + } + + private deleteProcessAttachToken(token: string, expected?: AuxiliaryAttachToken): void { + const attach = this.processAttachTokens.get(token); + if (!attach || (expected && attach !== expected)) return; + clearTimeout(attach.timeout); + this.processAttachTokens.delete(token); + if (this.processTokenByMainWebSocket.get(attach.mainWebSocket) === token) { + this.processTokenByMainWebSocket.delete(attach.mainWebSocket); + } + } + private deleteSFTPAttachToken(token: string, expected?: SFTPAttachToken): void { const attach = this.sftpAttachTokens.get(token); if (!attach || (expected && attach !== expected)) return; @@ -260,13 +347,13 @@ export class SSHSessionDO implements DurableObject { } } - private isValidSFTPAttachUrl(value: string, token: string): boolean { + private isValidAttachUrl(value: string, token: string, pathname: '/api/sftp' | '/api/processes'): boolean { if (value.length > 2048) return false; try { const url = new URL(value, 'https://session.invalid'); - return value.startsWith('/api/sftp?') + return value.startsWith(`${pathname}?`) && url.origin === 'https://session.invalid' - && url.pathname === '/api/sftp' + && url.pathname === pathname && !url.hash && url.searchParams.get('token') === token && url.searchParams.get('session') === this.state.id.toString(); @@ -316,6 +403,8 @@ export class SSHSessionDO implements DurableObject { this.clearDeadline(ws); const token = this.sftpTokenByMainWebSocket.get(ws); if (token) this.deleteSFTPAttachToken(token); + const processToken = this.processTokenByMainWebSocket.get(ws); + if (processToken) this.deleteProcessAttachToken(processToken); const sftpWebSockets = this.sftpWebSocketsByMain.get(ws); if (sftpWebSockets) { for (const sftpWebSocket of [...sftpWebSockets]) { @@ -324,6 +413,14 @@ export class SSHSessionDO implements DurableObject { } this.sftpWebSocketsByMain.delete(ws); } + const processWebSockets = this.processWebSocketsByMain.get(ws); + if (processWebSockets) { + for (const processWebSocket of [...processWebSockets]) { + this.cleanupProcessWebSocket(processWebSocket); + try { processWebSocket.close(1000, 'SSH session closed'); } catch { /* already closed */ } + } + this.processWebSocketsByMain.delete(ws); + } const pending = this.pendingConnections.get(ws); if (pending) { pending.cancelled = true; @@ -353,6 +450,25 @@ export class SSHSessionDO implements DurableObject { return attachment?.role === 'sftp'; } + private cleanupProcessWebSocket(ws: WebSocket): void { + const session = this.processSessions.get(ws); + if (!session) return; + this.processSessions.delete(ws); + try { session.detachProcessWebSocket(ws); } catch { /* session is already closed */ } + const mainWebSocket = this.processOwners.get(ws); + this.processOwners.delete(ws); + if (!mainWebSocket) return; + const sockets = this.processWebSocketsByMain.get(mainWebSocket); + sockets?.delete(ws); + if (sockets?.size === 0) this.processWebSocketsByMain.delete(mainWebSocket); + } + + private isProcessWebSocket(ws: WebSocket): boolean { + if (this.processSessions.has(ws)) return true; + const attachment = ws.deserializeAttachment() as Attachment | null; + return attachment?.role === 'process'; + } + private assertConnectionActive(ws: WebSocket, pending: PendingConnection, socket?: Socket): void { if (!pending.cancelled && this.pendingConnections.get(ws) === pending && ws.readyState === WebSocket.OPEN) return; if (socket) this.closePendingSocket(pending, socket); diff --git a/src/backend/process-framing.ts b/src/backend/process-framing.ts new file mode 100644 index 0000000..e5e7119 --- /dev/null +++ b/src/backend/process-framing.ts @@ -0,0 +1,7 @@ +export function retainTrailingMarkerPrefix(buffer: string, marker: string): string { + const maxLength = Math.min(buffer.length, Math.max(0, marker.length - 1)); + for (let length = maxLength; length > 0; length--) { + if (buffer.endsWith(marker.slice(0, length))) return buffer.slice(-length); + } + return ''; +} diff --git a/src/backend/session.ts b/src/backend/session.ts index 76bdd01..1367684 100644 --- a/src/backend/session.ts +++ b/src/backend/session.ts @@ -46,6 +46,8 @@ import { SSHChannel, type ChannelDataChunk } from '../ssh/channel'; import { SFTPHandler } from './sftp-handler'; import { classifyHostKey } from '../ssh/host-key'; import { encodeString, readUint32, toBufferSource } from '../ssh/utils'; +import { parseTopSnapshot } from './top-parser'; +import { retainTrailingMarkerPrefix } from './process-framing'; type Cipher = SSHAESGCMCipher | SSHAESCTRCipher; type Phase = 'version' | 'kex' | 'host-confirm' | 'auth' | 'pty' | 'shell' | 'ready' | 'closed'; @@ -55,11 +57,22 @@ interface PendingSFTPChannelOpen { timeout: ReturnType | null; cancelled: boolean; } +interface PendingProcessChannelOpen { + readonly channelID: number; + readonly channel: SSHChannel; + timeout: ReturnType | null; + cancelled: boolean; +} const LOCAL_WINDOW_THRESHOLD = 512 * 1024; const MAX_VERSION_BYTES = 8192; const MAX_QUEUED_INPUT = 1024 * 1024; const MAX_QUEUED_SFTP_UPLOAD = 1024 * 1024; const SFTP_CHANNEL_OPEN_TIMEOUT_MS = 15_000; +const PROCESS_CHANNEL_OPEN_TIMEOUT_MS = 15_000; +const PROCESS_MAX_BUFFER_BYTES = 2 * 1024 * 1024; +const PROCESS_SNAPSHOT_MARKER = '__CF_WEBSSH_TOP_SNAPSHOT__'; +// Octal escapes keep the delimiter itself out of the command line shown by top. +const PROCESS_MONITOR_COMMAND = "LC_ALL=C LANG=C sh -c 'while :; do printf \"\\137\\137CF_WEBSSH_TOP_SNAPSHOT\\137\\137\\n\"; top -b -n 1 -w 512 2>/dev/null || top -b -n 1 2>/dev/null || exit 127; sleep 2; done'"; const KEEPALIVE_NAME = new TextEncoder().encode('keepalive@openssh.com'); export class SSHSession { @@ -108,6 +121,12 @@ export class SSHSession { private sftpTaskChain: Promise = Promise.resolve(); private sftpQueuedUploadBytes = 0; private pendingSFTPChannelOpen: PendingSFTPChannelOpen | null = null; + private processChannel: SSHChannel | null = null; + private processWebSocket: WebSocket | null = null; + private processAttachUrl = ''; + private processBuffer = ''; + private processDecoder = new TextDecoder(); + private pendingProcessChannelOpen: PendingProcessChannelOpen | null = null; private ignoreNextKexPacket = false; private inputQueue: Uint8Array[] = []; private queueHeadOffset = 0; @@ -182,9 +201,14 @@ export class SSHSession { this.sftpHandler = null; this.sftpChannel = null; this.clearPendingSFTPChannelOpen(); + this.clearPendingProcessChannelOpen(); + this.processChannel = null; + this.processBuffer = ''; this.channels.clear(); try { this.sftpWebSocket?.close(normal ? 1000 : 1011, normal ? 'SSH session closed' : 'SSH session failed'); } catch { /* already closed */ } this.sftpWebSocket = null; + try { this.processWebSocket?.close(normal ? 1000 : 1011, normal ? 'SSH session closed' : 'SSH session failed'); } catch { /* already closed */ } + this.processWebSocket = null; this.pendingHostConfirmation?.resolve(false); this.pendingHostConfirmation = null; this.config.password = undefined; @@ -590,16 +614,27 @@ export class SSHSession { await this.sendSFTPChannelClose(channel); throw error; } + } else if (channel === this.processChannel && this.processWebSocket && !this.pendingProcessChannelOpen?.cancelled) { + try { + await this.sendEncrypted(channel.buildExecRequest(PROCESS_MONITOR_COMMAND)); + } catch (error) { + this.clearPendingProcessChannelOpen(channel); + this.processChannel = null; + await this.sendAuxiliaryChannelClose(channel); + throw error; + } } else { - // The file WebSocket may close while the channel open is in flight. + // An attachment WebSocket may close while its channel is opening. this.clearPendingSFTPChannelOpen(channel); - await this.sendSFTPChannelClose(channel); + this.clearPendingProcessChannelOpen(channel); + await this.sendAuxiliaryChannelClose(channel); } return; } if (type === SSH_MSG_CHANNEL_OPEN_FAILURE) { channel.handleOpenFailure(payload); this.clearPendingSFTPChannelOpen(channel); + this.clearPendingProcessChannelOpen(channel); this.channels.delete(channelID); if (isShell) throw new Error('SSH server rejected the session channel'); if (channel === this.sftpChannel) { @@ -607,6 +642,9 @@ export class SSHSession { this.sftpHandler = null; this.sftpChannel = null; this.sendSFTPError('init', 'SSH server rejected the SFTP channel'); + } else if (channel === this.processChannel) { + this.processChannel = null; + this.sendProcessError('SSH server rejected the process-monitor channel'); } return; } @@ -637,6 +675,15 @@ export class SSHSession { this.sftpChannel = null; this.sendSFTPError('init', 'SSH server rejected the SFTP subsystem'); await this.sendSFTPChannelClose(channel); + } else if (channel === this.processChannel) { + this.clearPendingProcessChannelOpen(channel); + if (type === SSH_MSG_CHANNEL_FAILURE) { + this.processChannel = null; + this.sendProcessError('SSH server rejected the top command'); + await this.sendAuxiliaryChannelClose(channel); + } else { + this.sendProcessJson({ type: 'process_ready' }); + } } return; } @@ -652,6 +699,8 @@ export class SSHSession { this.sendSFTPError('protocol', error instanceof Error ? error.message : String(error)); await this.closeSFTPChannel(); } + } else if (channel === this.processChannel) { + this.consumeProcessOutput(output); } await this.adjustLocalWindow(channel); return; @@ -663,6 +712,9 @@ export class SSHSession { this.ws.send(output); } else if (channel === this.sftpChannel) { this.sendSFTPError('protocol', new TextDecoder().decode(output) || 'SFTP channel reported an error'); + } else if (channel === this.processChannel) { + const message = new TextDecoder().decode(output).trim(); + if (message) this.sendProcessError(message.slice(0, 512)); } await this.adjustLocalWindow(channel); return; @@ -677,6 +729,7 @@ export class SSHSession { channel.handleEof(payload); if (isShell) this.status('remote_eof', 'SSH server finished sending output'); else if (channel === this.sftpChannel) this.sftpHandler?.onClosed(); + else if (channel === this.processChannel) this.flushProcessBuffer(); return; } if (type === SSH_MSG_CHANNEL_CLOSE) { @@ -692,6 +745,11 @@ export class SSHSession { this.sftpHandler?.onClosed(); this.sftpHandler = null; this.sftpChannel = null; + } else if (channel === this.processChannel) { + this.clearPendingProcessChannelOpen(channel); + this.flushProcessBuffer(); + this.processChannel = null; + this.sendProcessError('The process monitor stopped'); } } return; @@ -710,6 +768,7 @@ export class SSHSession { }); this.status('shell_ready', 'Shell is ready'); if (this.sftpAttachUrl) this.sendJson({ type: 'sftp_attach', url: this.sftpAttachUrl }); + if (this.processAttachUrl) this.sendJson({ type: 'process_attach', url: this.processAttachUrl }); void this.flushInput(); } @@ -718,6 +777,141 @@ export class SSHSession { this.sftpAttachUrl = url; } + setProcessAttachUrl(url: string): void { + if (!/^\/api\/processes\?/.test(url)) throw new Error('Invalid process attach URL'); + this.processAttachUrl = url; + } + + attachProcessWebSocket(ws: WebSocket): void { + if (this.phase === 'closed' || this.processWebSocket) { + try { ws.close(1008, 'Process monitor is unavailable'); } catch { /* already closed */ } + return; + } + this.processWebSocket = ws; + } + + detachProcessWebSocket(ws: WebSocket): void { + if (this.processWebSocket !== ws) return; + this.processWebSocket = null; + void this.closeProcessChannel(); + } + + async handleProcessClientMessage(message: string | ArrayBuffer): Promise { + if (this.phase === 'closed' || !this.processWebSocket) return; + if (message instanceof ArrayBuffer || message.length > 4096) throw new Error('Invalid process-monitor message'); + let decoded: unknown; + try { decoded = JSON.parse(message); } catch { throw new Error('Invalid process-monitor JSON'); } + if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) throw new Error('Invalid process-monitor message'); + const value = decoded as Record; + if (value.type === 'ping') { this.sendProcessJson({ type: 'pong' }); return; } + if (value.type === 'process_start') { await this.openProcessChannel(); return; } + if (value.type === 'process_stop') { await this.closeProcessChannel(); return; } + throw new Error('Unsupported process-monitor message'); + } + + private async openProcessChannel(): Promise { + if (!this.processWebSocket) throw new Error('Process-monitor WebSocket is not attached'); + if (this.phase !== 'ready') throw new Error('SSH connection is not ready'); + if (this.pendingProcessChannelOpen?.cancelled) throw new Error('The previous process channel is still closing'); + if (this.processChannel) { + this.sendProcessJson({ type: 'process_ready' }); + return; + } + const channelID = this.nextChannelID++; + const channel = new SSHChannel(); + const pending: PendingProcessChannelOpen = { channelID, channel, timeout: null, cancelled: false }; + this.channels.set(channelID, channel); + this.processChannel = channel; + this.processBuffer = ''; + this.processDecoder = new TextDecoder(); + this.pendingProcessChannelOpen = pending; + try { + await this.sendEncrypted(channel.buildOpenSession(channelID)); + if (this.pendingProcessChannelOpen === pending) { + pending.timeout = setTimeout(() => this.expirePendingProcessChannelOpen(pending), PROCESS_CHANNEL_OPEN_TIMEOUT_MS); + } + } catch (error) { + this.clearPendingProcessChannelOpen(channel); + this.channels.delete(channelID); + this.processChannel = null; + throw error; + } + } + + private async closeProcessChannel(): Promise { + const channel = this.processChannel; + const pending = this.pendingProcessChannelOpen; + if (pending?.channel === channel) pending.cancelled = true; + this.processChannel = null; + this.processBuffer = ''; + if (!channel?.isOpen()) return; + await this.sendAuxiliaryChannelClose(channel); + } + + private consumeProcessOutput(data: Uint8Array): void { + this.processBuffer += this.processDecoder.decode(data, { stream: true }); + if (this.processBuffer.length > PROCESS_MAX_BUFFER_BYTES) { + const marker = this.processBuffer.lastIndexOf(PROCESS_SNAPSHOT_MARKER); + this.processBuffer = marker >= 0 + ? this.processBuffer.slice(marker) + : retainTrailingMarkerPrefix(this.processBuffer, PROCESS_SNAPSHOT_MARKER); + this.sendProcessError('Process snapshot exceeded the buffer limit'); + return; + } + while (true) { + const first = this.processBuffer.indexOf(PROCESS_SNAPSHOT_MARKER); + if (first < 0) { + this.processBuffer = retainTrailingMarkerPrefix(this.processBuffer, PROCESS_SNAPSHOT_MARKER); + return; + } + if (first > 0) this.processBuffer = this.processBuffer.slice(first); + const next = this.processBuffer.indexOf(PROCESS_SNAPSHOT_MARKER, PROCESS_SNAPSHOT_MARKER.length); + if (next < 0) return; + this.emitProcessSnapshot(this.processBuffer.slice(PROCESS_SNAPSHOT_MARKER.length, next)); + this.processBuffer = this.processBuffer.slice(next); + } + } + + private flushProcessBuffer(): void { + this.processBuffer += this.processDecoder.decode(); + const marker = this.processBuffer.lastIndexOf(PROCESS_SNAPSHOT_MARKER); + if (marker >= 0) this.emitProcessSnapshot(this.processBuffer.slice(marker + PROCESS_SNAPSHOT_MARKER.length)); + this.processBuffer = ''; + this.processDecoder = new TextDecoder(); + } + + private emitProcessSnapshot(raw: string): void { + const snapshot = parseTopSnapshot(raw); + if (snapshot) this.sendProcessJson({ type: 'process_snapshot', ...snapshot }); + } + + private expirePendingProcessChannelOpen(expected: PendingProcessChannelOpen): void { + if (this.pendingProcessChannelOpen !== expected) return; + this.pendingProcessChannelOpen = null; + expected.timeout = null; + expected.cancelled = true; + if (this.processChannel === expected.channel) this.processChannel = null; + this.sendProcessError('Timed out opening the process-monitor channel'); + if (expected.channel.isOpen()) void this.sendAuxiliaryChannelClose(expected.channel); + try { this.processWebSocket?.close(1011, 'Process channel setup timed out'); } catch { /* already closed */ } + } + + private clearPendingProcessChannelOpen(channel?: SSHChannel): void { + const pending = this.pendingProcessChannelOpen; + if (!pending || (channel && pending.channel !== channel)) return; + if (pending.timeout) clearTimeout(pending.timeout); + pending.timeout = null; + this.pendingProcessChannelOpen = null; + } + + private sendProcessJson(value: unknown): void { + if (this.processWebSocket?.readyState === WebSocket.OPEN) this.processWebSocket.send(JSON.stringify(value)); + } + + private sendProcessError(message: string): void { + this.sendProcessJson({ type: 'process_error', message }); + } + attachSFTPWebSocket(ws: WebSocket): void { if (this.phase === 'closed' || this.sftpWebSocket) { try { ws.close(1008, 'SFTP connection is unavailable'); } catch { /* already closed */ } @@ -888,6 +1082,10 @@ export class SSHSession { } private async sendSFTPChannelClose(channel: SSHChannel): Promise { + await this.sendAuxiliaryChannelClose(channel); + } + + private async sendAuxiliaryChannelClose(channel: SSHChannel): Promise { if (!channel.isOpen() || channel.hasSentClose()) return; try { await this.sendEncrypted(channel.buildEof()); diff --git a/src/backend/top-parser.ts b/src/backend/top-parser.ts new file mode 100644 index 0000000..a5ed431 --- /dev/null +++ b/src/backend/top-parser.ts @@ -0,0 +1,159 @@ +export interface ProcessMetrics { + cpuPercent: number | null; + loadAverage: [number, number, number] | null; + memory: ResourceUsage | null; + swap: ResourceUsage | null; +} + +export interface ResourceUsage { + usedBytes: number; + totalBytes: number; + percent: number; +} + +export interface ProcessEntry { + pid: number; + user: string; + memoryBytes: number | null; + memoryPercent: number | null; + cpuPercent: number | null; + state: string; + time: string; + command: string; +} + +export interface ProcessSnapshot { + metrics: ProcessMetrics; + processes: ProcessEntry[]; + timestamp: number; +} + +const ANSI_ESCAPE_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g; +const MAX_PROCESSES = 512; + +function finiteNumber(value: string | undefined): number | null { + if (!value) return null; + const parsed = Number(value.replace(',', '.').replace(/%$/, '')); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +function parseLoad(lines: string[]): [number, number, number] | null { + for (const line of lines) { + const match = line.match(/load average:\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i); + if (!match) continue; + const values = match.slice(1, 4).map((value) => finiteNumber(value)); + if (values.every((value): value is number => value !== null)) return values as [number, number, number]; + } + return null; +} + +function parseCPU(lines: string[]): number | null { + for (const line of lines) { + if (!/(?:^|\s)(?:%?Cpu\(s\)|CPU):/i.test(line)) continue; + const idle = line.match(/([\d.]+)\s*%?\s*(?:id|idle)\b/i); + const idlePercent = finiteNumber(idle?.[1]); + if (idlePercent !== null) return clampPercent(100 - idlePercent); + } + return null; +} + +function unitMultiplier(label: string | undefined): number { + const unit = label?.toLowerCase() ?? ''; + if (unit.startsWith('g')) return 1024 ** 3; + if (unit.startsWith('m')) return 1024 ** 2; + if (unit.startsWith('k')) return 1024; + return 1; +} + +function parseUsage(lines: string[], kind: 'Mem' | 'Swap'): ResourceUsage | null { + for (const line of lines) { + if (!new RegExp(`(?:^|\\s)(?:KiB|MiB|GiB)?\\s*${kind}\\s*:`, 'i').test(line)) continue; + const unit = line.match(/(?:^|\s)(KiB|MiB|GiB)\s*(?:Mem|Swap)\s*:/i)?.[1]; + const multiplier = unitMultiplier(unit ?? 'KiB'); + const amount = (label: 'total' | 'used' | 'free'): number | null => { + const match = line.match(new RegExp(`([\\d.]+)\\s*([kmgt]?)\\s+${label}\\b`, 'i')); + const value = finiteNumber(match?.[1]); + return value === null ? null : value * (match?.[2] ? unitMultiplier(match[2]) : multiplier); + }; + const total = amount('total'); + const used = amount('used'); + const free = amount('free'); + const resolvedTotal = total ?? (used !== null && free !== null ? used + free : null); + if (resolvedTotal === null || used === null || resolvedTotal < 0) continue; + const totalBytes = Math.round(resolvedTotal); + const usedBytes = Math.min(totalBytes, Math.round(used)); + return { + usedBytes, + totalBytes, + percent: totalBytes > 0 ? clampPercent((usedBytes / totalBytes) * 100) : 0, + }; + } + return null; +} + +function headerIndex(headers: string[], ...names: string[]): number { + return headers.findIndex((header) => names.includes(header.toUpperCase())); +} + +function parseMemoryValue(value: string | undefined): number | null { + if (!value || !/^[\d.]+[kmgtpe]?b?$/i.test(value)) return null; + const match = value.match(/^([\d.]+)([kmgtpe]?)/i); + const amount = finiteNumber(match?.[1]); + if (amount === null) return null; + const suffix = match?.[2].toLowerCase() ?? ''; + const power = suffix ? 'kmgtpe'.indexOf(suffix) + 1 : 1; + return Math.round(amount * (1024 ** power)); +} + +function parseProcesses(lines: string[]): ProcessEntry[] { + const headerLineIndex = lines.findIndex((line) => /^\s*PID\s+/i.test(line) && /(?:^|\s)%?CPU(?:\s|$)/i.test(line)); + if (headerLineIndex < 0) return []; + const headers = lines[headerLineIndex].trim().split(/\s+/); + const pidIndex = headerIndex(headers, 'PID'); + const userIndex = headerIndex(headers, 'USER', 'USERNAME'); + const cpuIndex = headerIndex(headers, '%CPU', 'CPU%'); + const residentMemoryIndex = headerIndex(headers, 'RES', 'RSS'); + const memoryIndex = residentMemoryIndex >= 0 ? residentMemoryIndex : headerIndex(headers, 'VSZ', 'VIRT'); + const memoryPercentIndex = headerIndex(headers, '%MEM', 'MEM%', '%VSZ'); + const stateIndex = headerIndex(headers, 'S', 'STAT', 'STATE'); + const timeIndex = headerIndex(headers, 'TIME+', 'TIME'); + const commandIndex = headerIndex(headers, 'COMMAND', 'CMD', 'COMMAND+'); + if (pidIndex < 0 || commandIndex < 0) return []; + + const processes: ProcessEntry[] = []; + for (const line of lines.slice(headerLineIndex + 1)) { + if (!line.trim()) continue; + const fields = line.trim().split(/\s+/); + const pid = Number(fields[pidIndex]); + if (!Number.isSafeInteger(pid) || pid <= 0 || fields.length <= commandIndex) continue; + processes.push({ + pid, + user: userIndex >= 0 ? fields[userIndex] ?? '' : '', + memoryBytes: memoryIndex >= 0 ? parseMemoryValue(fields[memoryIndex]) : null, + memoryPercent: memoryPercentIndex >= 0 ? finiteNumber(fields[memoryPercentIndex]) : null, + cpuPercent: cpuIndex >= 0 ? finiteNumber(fields[cpuIndex]) : null, + state: stateIndex >= 0 ? fields[stateIndex] ?? '' : '', + time: timeIndex >= 0 ? fields[timeIndex] ?? '' : '', + command: fields.slice(commandIndex).join(' '), + }); + if (processes.length >= MAX_PROCESSES) break; + } + return processes; +} + +export function parseTopSnapshot(raw: string, timestamp = Date.now()): ProcessSnapshot | null { + const lines = raw.replace(ANSI_ESCAPE_RE, '').replace(/\r/g, '').split('\n'); + const processes = parseProcesses(lines); + const metrics: ProcessMetrics = { + cpuPercent: parseCPU(lines), + loadAverage: parseLoad(lines), + memory: parseUsage(lines, 'Mem'), + swap: parseUsage(lines, 'Swap'), + }; + if (processes.length === 0 && Object.values(metrics).every((value) => value === null)) return null; + return { metrics, processes, timestamp }; +} diff --git a/src/ssh/channel.ts b/src/ssh/channel.ts index ebccb92..7fe4f3a 100644 --- a/src/ssh/channel.ts +++ b/src/ssh/channel.ts @@ -11,6 +11,7 @@ import { encodeString, readUint32, writeUint32 } from './utils'; const SESSION_FIELD = encodeString('session'); const PTY_REQ_FIELD = encodeString('pty-req'); const SHELL_FIELD = encodeString('shell'); +const EXEC_FIELD = encodeString('exec'); const SUBSYSTEM_FIELD = encodeString('subsystem'); const WINDOW_CHANGE_FIELD = encodeString('window-change'); const EMPTY_TERMINAL_MODES_FIELD = encodeString(new Uint8Array([0])); @@ -145,6 +146,23 @@ export class SSHChannel { return payload; } + buildExecRequest(command: string): Uint8Array { + const commandBytes = new TextEncoder().encode(command); + if (commandBytes.length === 0 || commandBytes.length > 4096 || /[\0\r\n]/.test(command)) { + throw new Error('Invalid SSH exec command'); + } + const commandField = encodeString(commandBytes); + const payload = new Uint8Array(1 + 4 + EXEC_FIELD.length + 1 + commandField.length); + let offset = 0; + payload[offset++] = SSH_MSG_CHANNEL_REQUEST; + writeUint32(payload, offset, this.getRemoteChannelID()); + offset += 4; + offset = writeBytes(payload, offset, EXEC_FIELD); + payload[offset++] = 0x01; + writeBytes(payload, offset, commandField); + return payload; + } + buildSubsystemRequest(subsystem: string): Uint8Array { if (!/^[A-Za-z0-9@._+-]{1,64}$/.test(subsystem)) throw new Error('Invalid SSH subsystem name'); const subsystemField = encodeString(subsystem); diff --git a/src/worker.ts b/src/worker.ts index c8ef1e4..83c2dac 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -51,13 +51,19 @@ async function sshUpgrade(request: Request, env: Env): Promise { headers.delete('Cookie'); headers.delete('Authorization'); const sftpAttachToken = crypto.randomUUID(); + const processAttachToken = crypto.randomUUID(); const sftpAttachUrl = new URL('/api/sftp', 'https://session.invalid'); sftpAttachUrl.searchParams.set('session', id.toString()); sftpAttachUrl.searchParams.set('token', sftpAttachToken); + const processAttachUrl = new URL('/api/processes', 'https://session.invalid'); + processAttachUrl.searchParams.set('session', id.toString()); + processAttachUrl.searchParams.set('token', processAttachToken); headers.set('x-session-ticket', ticket); headers.set('x-client-ip', clientAddress(request)); headers.set('x-sftp-attach-token', sftpAttachToken); headers.set('x-sftp-attach-url', `${sftpAttachUrl.pathname}${sftpAttachUrl.search}`); + headers.set('x-process-attach-token', processAttachToken); + headers.set('x-process-attach-url', `${processAttachUrl.pathname}${processAttachUrl.search}`); const response = await env.SSH_SESSIONS.get(id).fetch(new Request('https://session.internal/connect', { headers })); if (response.status === 401) return jsonError('Invalid, expired, or already used session ticket', 401); return response; @@ -87,6 +93,30 @@ async function sftpUpgrade(request: Request, env: Env): Promise { return response; } +async function processUpgrade(request: Request, env: Env): Promise { + if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') return jsonError('WebSocket upgrade required', 426); + if (!hasValidWebSocketOrigin(request)) return jsonError('WebSocket origin is not allowed', 403); + const url = new URL(request.url); + const sessionId = url.searchParams.get('session'); + const token = url.searchParams.get('token'); + if (!sessionId || !token) return jsonError('Missing process attachment authorization', 401); + let id: DurableObjectId; + try { id = env.SSH_SESSIONS.idFromString(sessionId); } catch { return jsonError('Invalid session identifier', 401); } + const headers = new Headers(request.headers); + headers.delete('Cookie'); + headers.delete('Authorization'); + headers.delete('x-session-ticket'); + headers.delete('x-client-ip'); + headers.delete('x-process-attach-url'); + headers.set('x-process-attach-token', token); + const response = await env.SSH_SESSIONS.get(id).fetch(new Request('https://session.internal/processes', { + method: 'GET', + headers, + })); + if (response.status === 401) return jsonError('Invalid, expired, or already used process attachment token', 401); + return response; +} + export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); @@ -117,6 +147,10 @@ export default { if (request.method !== 'GET') return corsResponse(jsonError('Method not allowed', 405)); return corsResponse(await sftpUpgrade(request, env)); } + if (url.pathname === '/api/processes') { + if (request.method !== 'GET') return corsResponse(jsonError('Method not allowed', 405)); + return corsResponse(await processUpgrade(request, env)); + } if (isApiRequest) return corsResponse(jsonError('Not found', 404)); if (!env.ASSETS) return jsonError('Static assets binding is not configured', 503); return secureResponse(await env.ASSETS.fetch(request));