Merge pull request #6 from cmliu/beta

Beta
This commit is contained in:
CMLiussss authored and GitHub committed 2026-07-29 21:32:39 +08:00
commit 46f234acb2
11 files changed
+1440 -25

No files matched your search

+2
View File
@@ -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 和限流策略提供。
+67
View File
@@ -263,6 +263,32 @@
<span id="session-subtitle">选择目标并连接</span>
</div>
</div>
<div class="resource-metrics" aria-label="系统资源" data-i18n-aria-label-zh="系统资源" data-i18n-aria-label-en="System resources">
<div class="resource-metric resource-cpu">
<div class="resource-metric-line">
<span data-i18n-zh="CPU" data-i18n-en="CPU">CPU</span>
<strong id="resource-cpu-value" class="resource-percentage">--</strong>
<strong id="resource-cpu-load" class="resource-detail">-- / -- / --</strong>
</div>
<progress id="resource-cpu-progress" max="100" value="0" aria-label="CPU 使用率" data-i18n-aria-label-zh="CPU 使用率" data-i18n-aria-label-en="CPU usage"></progress>
</div>
<div class="resource-metric resource-memory">
<div class="resource-metric-line">
<span data-i18n-zh="内存" data-i18n-en="MEM">内存</span>
<strong id="resource-memory-value" class="resource-percentage">--</strong>
<strong id="resource-memory-amount" class="resource-detail">--/--</strong>
</div>
<progress id="resource-memory-progress" max="100" value="0" aria-label="内存使用率" data-i18n-aria-label-zh="内存使用率" data-i18n-aria-label-en="Memory usage"></progress>
</div>
<div class="resource-metric resource-swap">
<div class="resource-metric-line">
<span data-i18n-zh="Swap" data-i18n-en="SWAP">Swap</span>
<strong id="resource-swap-value" class="resource-percentage">--</strong>
<strong id="resource-swap-amount" class="resource-detail">--/--</strong>
</div>
<progress id="resource-swap-progress" max="100" value="0" aria-label="Swap 使用率" data-i18n-aria-label-zh="Swap 使用率" data-i18n-aria-label-en="Swap usage"></progress>
</div>
</div>
<div class="session-metrics">
<div><span data-i18n-zh="延迟" data-i18n-en="RTT">延迟</span><strong id="metric-rtt">--</strong></div>
<div><span data-i18n-zh="时长" data-i18n-en="UPTIME">时长</span><strong id="metric-uptime">00:00</strong></div>
@@ -329,6 +355,22 @@
<svg class="dock-tab-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3.5 7.5h6l2-2h9a1.5 1.5 0 0 1 1.5 1.5v11a1.5 1.5 0 0 1-1.5 1.5h-17A1.5 1.5 0 0 1 2 18V9a1.5 1.5 0 0 1 1.5-1.5Z" /></svg>
<span class="button-label" data-i18n-zh="文件管理" data-i18n-en="Files">文件管理</span>
</button>
<button
id="process-manager-tab"
class="dock-tab"
type="button"
role="tab"
aria-selected="false"
aria-controls="process-manager-panel"
aria-expanded="false"
tabindex="-1"
aria-label="进程管理"
data-i18n-aria-label-zh="进程管理"
data-i18n-aria-label-en="Processes"
>
<svg class="dock-tab-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" /><path d="m7 9 2.5 2.5L7 14M12.5 14H17" /></svg>
<span class="button-label" data-i18n-zh="进程管理" data-i18n-en="Processes">进程管理</span>
</button>
<button
id="event-toggle"
class="dock-tab"
@@ -409,6 +451,31 @@
</div>
</div>
</section>
<section id="process-manager-panel" class="process-manager" role="tabpanel" aria-labelledby="process-manager-tab" tabindex="0" hidden>
<div class="process-table-wrap">
<table class="process-table" aria-describedby="process-manager-status">
<thead>
<tr>
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="pid" data-i18n-zh="PID" data-i18n-en="PID">PID</button></th>
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="user" data-i18n-zh="用户" data-i18n-en="User">用户</button></th>
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="memoryBytes" data-i18n-zh="内存" data-i18n-en="Memory">内存</button></th>
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="memoryPercent" data-i18n-zh="MEM%" data-i18n-en="MEM%">MEM%</button></th>
<th scope="col" aria-sort="descending"><button class="process-sort" type="button" data-process-sort="cpuPercent" data-i18n-zh="CPU%" data-i18n-en="CPU%">CPU%</button></th>
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="state" data-i18n-zh="状态" data-i18n-en="State">状态</button></th>
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="time" data-i18n-zh="时间" data-i18n-en="Time">时间</button></th>
<th scope="col" data-i18n-zh="名称/命令行" data-i18n-en="Name / command">名称/命令行</th>
</tr>
</thead>
<tbody id="process-table-body"></tbody>
</table>
<div id="process-manager-empty" class="process-manager-state" hidden data-i18n-zh="没有可显示的进程" data-i18n-en="No processes to display">没有可显示的进程</div>
<div id="process-manager-error" class="process-manager-state error" role="alert" hidden></div>
</div>
<div class="process-statusbar">
<span id="process-manager-status" role="status" aria-live="polite" data-i18n-zh="连接 SSH 后即可查看实时进程" data-i18n-en="Connect to SSH to view live processes">连接 SSH 后即可查看实时进程</span>
<span><span data-i18n-zh="最后更新" data-i18n-en="Last update">最后更新</span> <strong id="process-updated">--</strong></span>
</div>
</section>
<div id="event-log" class="event-log" role="tabpanel" aria-labelledby="event-toggle" tabindex="0" hidden aria-live="polite"></div>
</div>
</section>
+43 -4
View File
@@ -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<HTMLElement>('event-message'),
fileManagerTab: element<HTMLButtonElement>('file-manager-tab'),
fileManagerPanel: element<HTMLElement>('file-manager-panel'),
processManagerTab: element<HTMLButtonElement>('process-manager-tab'),
processManagerPanel: element<HTMLElement>('process-manager-panel'),
eventToggle: element<HTMLButtonElement>('event-toggle'),
eventLog: element<HTMLElement>('event-log'),
toastRegion: element<HTMLElement>('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<string, number>();
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<string, string> {
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<void> {
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) => {
+441
View File
@@ -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<ResourceUsage>;
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<ProcessEntry>;
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<ProcessSnapshot>;
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<HTMLProgressElement, number>();
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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-process-sort]')) {
const key = button.dataset.processSort as ProcessSortKey;
const header = button.closest<HTMLTableCellElement>('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<string, unknown>;
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<T extends HTMLElement>(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'),
};
}
+344 -10
View File
@@ -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;
}
}
+125 -9
View File
@@ -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<typeof setTimeout>;
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<WebSocket, SSHSession>();
private readonly sftpOwners = new Map<WebSocket, WebSocket>();
private readonly sftpWebSocketsByMain = new Map<WebSocket, Set<WebSocket>>();
private readonly processAttachTokens = new Map<string, AuxiliaryAttachToken>();
private readonly processTokenByMainWebSocket = new Map<WebSocket, string>();
private readonly processSessions = new Map<WebSocket, SSHSession>();
private readonly processOwners = new Map<WebSocket, WebSocket>();
private readonly processWebSocketsByMain = new Map<WebSocket, Set<WebSocket>>();
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<void> {
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<void> {
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<void> {
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<WebSocket>();
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);
+7
View File
@@ -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 '';
}
+200 -2
View File
@@ -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<typeof setTimeout> | null;
cancelled: boolean;
}
interface PendingProcessChannelOpen {
readonly channelID: number;
readonly channel: SSHChannel;
timeout: ReturnType<typeof setTimeout> | 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<void> = 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<void> {
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<string, unknown>;
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<void> {
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<void> {
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<void> {
await this.sendAuxiliaryChannelClose(channel);
}
private async sendAuxiliaryChannelClose(channel: SSHChannel): Promise<void> {
if (!channel.isOpen() || channel.hasSentClose()) return;
try {
await this.sendEncrypted(channel.buildEof());
+159
View File
@@ -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 };
}
+18
View File
@@ -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);
+34
View File
@@ -51,13 +51,19 @@ async function sshUpgrade(request: Request, env: Env): Promise<Response> {
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<Response> {
return response;
}
async function processUpgrade(request: Request, env: Env): Promise<Response> {
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<Response> {
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));