11 files changed
+1440
-25
No files matched your search
@@ -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 和限流策略提供。
|
||||
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user