Merge pull request #2 from cmliu/beta

Beta
This commit is contained in:
CMLiussss authored and GitHub committed 2026-07-29 11:26:25 +08:00
commit b7379b0f9c
14 files changed
+3668 -112

No files matched your search

+1
View File
@@ -7,3 +7,4 @@ coverage/
.env*
!.env.example
.workbuddy/
tests/
+7 -4
View File
@@ -11,6 +11,7 @@
- Cloudflare Workers 原生部署,使用 Durable Objects 隔离每个 SSH 会话。
- 基于 xterm.js 的响应式终端,支持桌面端和移动端、自动缩放、全屏和会话日志。
- 内置 SFTP 文件管理,可浏览目录、上传/下载文件、新建与删除空目录、删除文件和重命名。
- 支持 SSH password 与单密码提示的 keyboard-interactive 认证,以及 Ed25519、RSA、ECDSA 的未加密 OpenSSH 私钥认证。
- 首次连接时暂停认证并显示主机 SHA-256 指纹,确认后才会发送 SSH 凭据。
- SSH 连接成功后自动写入浏览器本地"历史记录",密码使用 AES-256-GCM 加密,私钥不保存。
@@ -23,7 +24,7 @@
```text
浏览器(xterm.js)
│ HTTPS:申请一次性会话票据
│ WSS:终端输入、输出和控制消息
│ WSS:终端输入、输出和控制消息;独立 SFTP 文件通道
▼
Cloudflare Worker
│ 同源检查、会话票据、静态资源
@@ -39,7 +40,7 @@ Cloudflare Worker
| 类别 | 当前支持 |
| --- | --- |
| SSH 协议 | SSH 2.0 交互式 Shell、PTY、窗口尺寸同步、Keepalive |
| SSH 协议 | SSH 2.0 交互式 Shell、PTY、SFTP v3、窗口尺寸同步、Keepalive |
| 用户认证 | Password、单密码提示 keyboard-interactive、OpenSSH Ed25519、RSA、ECDSA P-256/P-384/P-521 私钥 |
| 密钥交换 | `curve25519-sha256`、`ecdh-sha2-nistp256` |
| 主机密钥 | Ed25519、ECDSA P-256/P-384/P-521、RSA SHA-2 |
@@ -47,7 +48,7 @@ Cloudflare Worker
| MAC | HMAC-SHA2-256、HMAC-SHA2-512(AES-GCM 不使用独立 MAC) |
| 终端编码 | UTF-8、GB18030、Big5(取决于浏览器 `TextDecoder` 支持) |
**限制**:只能连接公网 IP 或解析结果全部为公网地址的域名;不支持出站 TCP 25 端口;不支持加密私钥、PEM/PKCS#8 私钥、SSH Agent、键盘交互认证、SFTP/SCP、端口转发、ProxyJump、SSH 压缩和会话内 rekey。
**限制**:只能连接公网 IP 或解析结果全部为公网地址的域名;不支持出站 TCP 25 端口;不支持加密私钥、PEM/PKCS#8 私钥、SSH Agent、多因素键盘交互认证、SCP、端口转发、ProxyJump、SSH 压缩和会话内 rekey。文件上传与下载单文件限制为 64 MiB,目录删除仅支持空目录。
## 部署教程
@@ -135,7 +136,8 @@ npm run dev:web # 访问 http://localhost:5173,Vite 代理 /api 到 8787
│ ├── backend/
│ │ ├── durable-object.ts # 会话票据、TCP Socket 与会话生命周期
│ │ ├── security.ts # 票据、同源与公网目标校验
│ │ └── session.ts # SSH 状态机与浏览器消息桥接
│ │ ├── session.ts # SSH 状态机与浏览器消息桥接
│ │ └── sftp-handler.ts # SFTP 文件操作与传输状态
│ ├── ssh/ # SSH 协议、KEX、密码学、认证与通道
│ ├── http-security.ts # HTTPS 与安全响应头
│ ├── types.ts # Worker 环境、连接消息和 SSH 类型
@@ -151,6 +153,7 @@ npm run dev:web # 访问 http://localhost:5173,Vite 代理 /api 到 8787
| `/api/health` | `GET` | 返回 Worker 与 SSH 功能健康状态 |
| `/api/session` | `POST` | 匿名创建一次性会话票据 |
| `/api/ssh?ticket=...&session=...` | `GET` + WebSocket Upgrade | 进入对应 Durable Object 并建立 SSH 会话 |
| `/api/sftp?session=...&token=...` | `GET` + WebSocket Upgrade | 使用一次性附着令牌进入当前会话的文件通道 |
所有 `/api/*` 响应均允许跨站访问,并支持浏览器 `OPTIONS` 预检请求。公网访问控制应由 Cloudflare Access、WAF 和限流策略提供。
+132 -40
View File
@@ -240,52 +240,144 @@
<main class="workspace">
<section id="terminal-card" class="terminal-card">
<div class="terminal-toolbar">
<div
class="session-target"
aria-label="实时连接状态"
data-i18n-aria-label-zh="实时连接状态"
data-i18n-aria-label-en="Live connection status"
>
<span class="live-orb" id="live-orb"></span>
<div>
<strong id="session-title">无活动会话</strong>
<span id="session-subtitle">选择目标并连接</span>
<div class="terminal-pane">
<div class="terminal-toolbar">
<div
class="session-target"
aria-label="实时连接状态"
data-i18n-aria-label-zh="实时连接状态"
data-i18n-aria-label-en="Live connection status"
>
<span class="live-orb" id="live-orb"></span>
<div>
<strong id="session-title">无活动会话</strong>
<span id="session-subtitle">选择目标并连接</span>
</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>
<div><span data-i18n-zh="主机密钥" data-i18n-en="HOST KEY">主机密钥</span><strong id="metric-host-key">--</strong></div>
</div>
<div class="terminal-actions">
<button id="clear-terminal" class="toolbar-button" type="button" data-i18n-zh="清屏" data-i18n-en="Clear">清屏</button>
<button id="fullscreen-terminal" class="toolbar-button" type="button" data-i18n-zh="全屏" data-i18n-en="Fullscreen">全屏</button>
</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>
<div><span data-i18n-zh="主机密钥" data-i18n-en="HOST KEY">主机密钥</span><strong id="metric-host-key">--</strong></div>
</div>
<div class="terminal-actions">
<button id="clear-terminal" class="toolbar-button" type="button" data-i18n-zh="清屏" data-i18n-en="Clear">清屏</button>
<button id="fullscreen-terminal" class="toolbar-button" type="button" data-i18n-zh="全屏" data-i18n-en="Fullscreen">全屏</button>
<div id="terminal-stage" class="terminal-stage">
<div
id="terminal"
role="application"
aria-label="SSH 终端"
data-i18n-aria-label-zh="SSH 终端"
data-i18n-aria-label-en="SSH terminal"
></div>
<div id="terminal-empty" class="terminal-empty">
<div class="empty-glyph" aria-hidden="true">&gt;_</div>
<h2 data-i18n-zh="终端已就绪" data-i18n-en="Your terminal is ready">终端已就绪</h2>
<p
data-i18n-zh="从连接面板建立 SSH 连接,开始加密的边缘会话。"
data-i18n-en="Open an SSH connection from the panel to start an encrypted edge session."
>从连接面板建立 SSH 连接,开始加密的边缘会话。</p>
<button id="empty-connect" class="small-button" type="button" data-i18n-zh="配置连接" data-i18n-en="Configure connection">配置连接</button>
</div>
</div>
</div>
<div id="terminal-stage" class="terminal-stage">
<div
id="terminal"
role="application"
aria-label="SSH 终端"
data-i18n-aria-label-zh="SSH 终端"
data-i18n-aria-label-en="SSH terminal"
></div>
<div id="terminal-empty" class="terminal-empty">
<div class="empty-glyph" aria-hidden="true">&gt;_</div>
<h2 data-i18n-zh="终端已就绪" data-i18n-en="Your terminal is ready">终端已就绪</h2>
<p
data-i18n-zh="从连接面板建立 SSH 连接,开始加密的边缘会话。"
data-i18n-en="Open an SSH connection from the panel to start an encrypted edge session."
>从连接面板建立 SSH 连接,开始加密的边缘会话。</p>
<button id="empty-connect" class="small-button" type="button" data-i18n-zh="配置连接" data-i18n-en="Configure connection">配置连接</button>
<div class="workspace-dock">
<div class="event-bar">
<span id="event-message">Worker 运行时待命</span>
<div class="dock-tabs" role="tablist" aria-label="工作区面板" data-i18n-aria-label-zh="工作区面板" data-i18n-aria-label-en="Workspace panels">
<button
id="file-manager-tab"
class="dock-tab"
type="button"
role="tab"
aria-selected="false"
aria-controls="file-manager-panel"
aria-expanded="false"
tabindex="0"
data-i18n-zh="文件管理"
data-i18n-en="Files"
>文件管理</button>
<button
id="event-toggle"
class="dock-tab"
type="button"
role="tab"
aria-selected="false"
aria-controls="event-log"
aria-expanded="false"
tabindex="-1"
data-i18n-zh="会话日志"
data-i18n-en="Session log"
>会话日志</button>
</div>
</div>
<section id="file-manager-panel" class="file-manager" role="tabpanel" aria-labelledby="file-manager-tab" tabindex="0" hidden>
<div class="file-toolbar">
<label class="file-path-field" for="file-manager-path">
<span class="sr-only" data-i18n-zh="当前路径" data-i18n-en="Current path">当前路径</span>
<input id="file-manager-path" type="text" value="/" autocomplete="off" spellcheck="false" aria-label="当前路径" data-i18n-aria-label-zh="当前路径" data-i18n-aria-label-en="Current path" />
</label>
<div class="file-actions" aria-label="文件操作" data-i18n-aria-label-zh="文件操作" data-i18n-aria-label-en="File actions">
<button id="file-back" class="file-action" type="button" disabled data-i18n-aria-label-zh="返回" data-i18n-aria-label-en="Back" aria-label="返回" title="返回" data-i18n-title-zh="返回" data-i18n-title-en="Back">&#8592;</button>
<button id="file-up" class="file-action" type="button" disabled data-i18n-aria-label-zh="上一级" data-i18n-aria-label-en="Up one level" aria-label="上一级" title="上一级" data-i18n-title-zh="上一级" data-i18n-title-en="Up one level">&#8593;</button>
<button id="file-home" class="file-action" type="button" disabled data-i18n-aria-label-zh="主目录" data-i18n-aria-label-en="Home" aria-label="主目录" title="主目录" data-i18n-title-zh="主目录" data-i18n-title-en="Home">&#8962;</button>
<button id="file-refresh" class="file-action" type="button" disabled data-i18n-aria-label-zh="刷新" data-i18n-aria-label-en="Refresh" aria-label="刷新" title="刷新" data-i18n-title-zh="刷新" data-i18n-title-en="Refresh">&#8635;</button>
<span class="file-action-separator" aria-hidden="true"></span>
<button id="file-upload" class="file-action file-action-wide" type="button" disabled data-i18n-zh="上传" data-i18n-en="Upload">上传</button>
<button id="file-download" class="file-action file-action-wide" type="button" disabled data-i18n-zh="下载" data-i18n-en="Download">下载</button>
<button id="file-mkdir" class="file-action file-action-wide" type="button" disabled data-i18n-zh="新建" data-i18n-en="New folder">新建</button>
<button id="file-rename" class="file-action file-action-wide" type="button" disabled data-i18n-zh="重命名" data-i18n-en="Rename">重命名</button>
<button id="file-delete" class="file-action file-action-wide danger" type="button" disabled data-i18n-zh="删除" data-i18n-en="Delete">删除</button>
<input id="file-upload-input" class="sr-only" type="file" tabindex="-1" />
</div>
</div>
<div class="file-table-wrap">
<table class="file-table" aria-describedby="file-manager-status">
<thead>
<tr>
<th scope="col" data-i18n-zh="名称" data-i18n-en="Name">名称</th>
<th scope="col" data-column="size" data-i18n-zh="大小" data-i18n-en="Size">大小</th>
<th scope="col" data-column="type" data-i18n-zh="类型" data-i18n-en="Type">类型</th>
<th scope="col" data-column="mtime" data-i18n-zh="修改时间" data-i18n-en="Modified">修改时间</th>
<th scope="col" data-column="permissions" data-i18n-zh="权限" data-i18n-en="Permissions">权限</th>
<th scope="col" data-column="owner" data-i18n-zh="用户" data-i18n-en="Owner">用户</th>
<th scope="col" data-column="group" data-i18n-zh="组" data-i18n-en="Group">组</th>
</tr>
</thead>
<tbody id="file-table-body"></tbody>
</table>
<div id="file-manager-loading" class="file-manager-state" role="status" hidden>
<span class="file-spinner" aria-hidden="true"></span>
<span data-i18n-zh="正在读取目录…" data-i18n-en="Loading directory…">正在读取目录…</span>
</div>
<div id="file-manager-empty" class="file-manager-state" hidden data-i18n-zh="此目录为空" data-i18n-en="This folder is empty">此目录为空</div>
<div id="file-manager-error" class="file-manager-state error" role="alert" hidden></div>
</div>
<div class="file-statusbar">
<span id="file-manager-status" role="status" aria-live="polite" data-i18n-zh="连接 SSH 后即可管理文件" data-i18n-en="Connect to SSH to manage files">连接 SSH 后即可管理文件</span>
<div id="file-manager-progress" class="file-progress" hidden>
<span id="file-manager-progress-label"></span>
<progress id="file-manager-progress-bar" max="100" value="0" aria-labelledby="file-manager-progress-label"></progress>
<button
id="file-manager-progress-cancel"
class="file-progress-cancel"
type="button"
data-i18n-zh="取消"
data-i18n-en="Cancel"
data-i18n-aria-label-zh="取消文件传输"
data-i18n-aria-label-en="Cancel file transfer"
aria-label="取消文件传输"
>取消</button>
</div>
</div>
</section>
<div id="event-log" class="event-log" role="tabpanel" aria-labelledby="event-toggle" tabindex="0" hidden aria-live="polite"></div>
</div>
<div class="event-bar">
<span id="event-message">Worker 运行时待命</span>
<button id="event-toggle" type="button" aria-expanded="false" data-i18n-zh="会话日志" data-i18n-en="Session log">会话日志</button>
</div>
<div id="event-log" class="event-log" hidden aria-live="polite"></div>
</section>
</main>
</div>
File diff suppressed because it is too large. Load diff
+74 -6
View File
@@ -6,6 +6,7 @@ import { getHistoryPasswordKey } from './history-key';
import { decryptPasswordResult, encryptPassword, isEncryptedPassword } from './password-crypto';
import { resolveConnectionControl, resolveConnectionPanel } from './ui-state';
import { classifyHostKey, SSH_FINGERPRINT_RE, type HostKeyPrompt } from './host-key';
import { FileManager, collectFileManagerElements } from './file-manager';
import './style.css';
type AuthMethod = 'password' | 'publickey';
@@ -70,6 +71,7 @@ interface ServerMessage {
colo?: string;
ts?: number;
algorithms?: Record<string, string>;
url?: string;
}
interface WSSHOptions {
@@ -183,6 +185,7 @@ const EVENT_LABELS: Record<string, Translation> = {
error: ['错误', 'error'],
debug: ['调试', 'debug'],
'host-key': ['主机密钥', 'host key'],
sftp: ['文件管理', 'files'],
};
const SERVER_EVENT_MESSAGES: Record<string, Translation> = {
@@ -287,6 +290,8 @@ const ui = {
clearTerminal: element<HTMLButtonElement>('clear-terminal'),
fullscreenTerminal: element<HTMLButtonElement>('fullscreen-terminal'),
eventMessage: element<HTMLElement>('event-message'),
fileManagerTab: element<HTMLButtonElement>('file-manager-tab'),
fileManagerPanel: element<HTMLElement>('file-manager-panel'),
eventToggle: element<HTMLButtonElement>('event-toggle'),
eventLog: element<HTMLElement>('event-log'),
toastRegion: element<HTMLElement>('toast-region'),
@@ -328,6 +333,9 @@ function applyLanguage(language: Language, persist = false): void {
for (const node of document.querySelectorAll<HTMLElement>('[data-i18n-aria-label-zh][data-i18n-aria-label-en]')) {
node.setAttribute('aria-label', language === 'zh-CN' ? node.dataset.i18nAriaLabelZh! : node.dataset.i18nAriaLabelEn!);
}
for (const node of document.querySelectorAll<HTMLElement>('[data-i18n-title-zh][data-i18n-title-en]')) {
node.setAttribute('title', language === 'zh-CN' ? node.dataset.i18nTitleZh! : node.dataset.i18nTitleEn!);
}
for (const node of document.querySelectorAll<HTMLElement>('[data-i18n-content-zh][data-i18n-content-en]')) {
node.setAttribute('content', language === 'zh-CN' ? node.dataset.i18nContentZh! : node.dataset.i18nContentEn!);
}
@@ -350,6 +358,7 @@ function applyLanguage(language: Language, persist = false): void {
copy.textContent = bilingual(copy.dataset.messageZh, copy.dataset.messageEn);
}
}
if (fileManager) fileManager.setLanguage();
if (persist) {
try { localStorage.setItem(LANGUAGE_STORAGE_KEY, language); } catch { /* Language still applies for this page. */ }
@@ -388,6 +397,7 @@ let keyFileReadGeneration = 0;
const latestHistoryMutation = new Map<string, number>();
const panelMedia = window.matchMedia('(max-width: 760px)');
let panelOpen = !panelMedia.matches;
let fileManager: FileManager;
const terminal = new Terminal({
allowProposedApi: false,
@@ -406,6 +416,11 @@ const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(new WebLinksAddon());
terminal.open(ui.terminalElement);
fileManager = new FileManager({
elements: collectFileManagerElements(),
getLanguage: () => currentLanguage,
onError: (message) => event(message, 'sftp', true),
});
function terminalTheme(): Record<string, string> {
return {
@@ -1195,6 +1210,38 @@ function sendHostKeyDecision(accept: boolean): void {
: bilingual('主机密钥已拒绝。', 'Host key rejected.'), 'host-key', !accept);
}
type WorkspaceTab = 'files' | 'log';
let activeWorkspaceTab: WorkspaceTab | null = null;
function setWorkspaceTab(tab: WorkspaceTab | null, focus = false, rovingTab = tab ?? activeWorkspaceTab ?? 'files'): void {
const filesActive = tab === 'files';
const logActive = tab === 'log';
activeWorkspaceTab = tab;
ui.terminalCard.classList.toggle('workspace-panel-open', tab !== null);
ui.fileManagerPanel.hidden = !filesActive;
ui.eventLog.hidden = !logActive;
ui.fileManagerTab.setAttribute('aria-selected', String(filesActive));
ui.eventToggle.setAttribute('aria-selected', String(logActive));
ui.fileManagerTab.setAttribute('aria-expanded', String(filesActive));
ui.eventToggle.setAttribute('aria-expanded', String(logActive));
ui.fileManagerTab.tabIndex = rovingTab === 'files' ? 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();
requestAnimationFrame(() => fitTerminal(true));
}
function toggleWorkspaceTab(tab: WorkspaceTab): void {
setWorkspaceTab(activeWorkspaceTab === tab ? null : tab, false, tab);
}
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);
}
function configureHostKeyDialog(hostKey: HostKeyPrompt): void {
const changed = hostKey.trust === 'changed';
ui.hostKeyDialog.classList.toggle('changed', changed);
@@ -1233,6 +1280,20 @@ function clearHostKeyPrompt(): void {
function handleServerMessage(message: ServerMessage): void {
const type = message.type ?? 'status';
if (type === 'sftp_attach') {
if (typeof message.url !== 'string' || !message.url.startsWith('/api/sftp?')) {
event(bilingual('收到无效的文件管理连接信息。', 'Received invalid file-management connection details.'), 'protocol', true);
return;
}
try {
fileManager.attach(message.url);
} catch {
event(bilingual('无法打开文件管理连接。', 'Could not open the file-management connection.'), 'sftp', true);
return;
}
event(bilingual('文件管理通道已可用。', 'File management channel is available.'), 'sftp');
return;
}
if (type === 'pong') {
if (lastPingAt > 0) ui.metricRtt.textContent = `${Math.max(1, Math.round(performance.now() - lastPingAt))} ms`;
return;
@@ -1352,6 +1413,7 @@ function failActiveConnection(activeSocket: WebSocket | null, closeReason: strin
currentExpectedFingerprint = '';
currentRememberedFingerprint = '';
stopTimers();
fileManager.reset();
clearHostKeyPrompt();
invalidateHistoryPasswordLoad();
currentSessionSubtitle = displayReason;
@@ -1493,6 +1555,7 @@ async function connect(): Promise<void> {
currentExpectedFingerprint = '';
currentRememberedFingerprint = '';
stopTimers();
fileManager.reset();
clearHostKeyPrompt();
invalidateHistoryPasswordLoad();
const wasActive = connectionState === 'connected';
@@ -1535,6 +1598,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected
const activeSocket = socket;
socket = null;
stopTimers();
fileManager.reset();
clearHostKeyPrompt();
invalidateHistoryPasswordLoad();
currentExpectedFingerprint = '';
@@ -1792,11 +1856,10 @@ ui.fullscreenTerminal.addEventListener('click', async () => {
else await ui.terminalCard.requestFullscreen();
});
document.addEventListener('fullscreenchange', () => fitTerminal(true));
ui.eventToggle.addEventListener('click', () => {
ui.eventLog.hidden = !ui.eventLog.hidden;
ui.eventToggle.setAttribute('aria-expanded', String(!ui.eventLog.hidden));
fitTerminal(true);
});
ui.fileManagerTab.addEventListener('click', () => toggleWorkspaceTab('files'));
ui.eventToggle.addEventListener('click', () => toggleWorkspaceTab('log'));
ui.fileManagerTab.addEventListener('keydown', handleWorkspaceTabKey);
ui.eventToggle.addEventListener('keydown', handleWorkspaceTabKey);
ui.languageToggle.addEventListener('click', () => {
applyLanguage(currentLanguage === 'zh-CN' ? 'en' : 'zh-CN', true);
renderProfiles();
@@ -1818,7 +1881,10 @@ ui.hostKeyDialog.addEventListener('close', () => {
});
terminal.onData(sendTerminalData);
new ResizeObserver(() => fitTerminal(true)).observe(ui.terminalStage);
window.addEventListener('beforeunload', () => socket?.close(1000, 'Page closed'));
window.addEventListener('beforeunload', () => {
fileManager.reset();
socket?.close(1000, 'Page closed');
});
document.addEventListener('keydown', (keyEvent) => {
if (keyEvent.key === 'Escape' && panelMedia.matches && panelOpen && !ui.hostKeyDialog.open) {
setPanelOpen(false);
@@ -1838,6 +1904,7 @@ async function initialize(): Promise<void> {
setPanelOpen(panelOpen);
setAuthMethod('password');
setState('idle');
setWorkspaceTab(null);
ui.sessionTitle.textContent = bilingual('无活动会话', 'No active session');
ui.sessionSubtitle.textContent = bilingual('选择目标并连接', 'Choose a target and connect');
ui.eventMessage.textContent = bilingual('Worker 运行时待命', 'Worker runtime standing by');
@@ -1858,5 +1925,6 @@ void initialize().catch(() => {
setPanelOpen(panelOpen);
setAuthMethod('password');
setState('idle');
setWorkspaceTab(null);
initializeCompatibilityAPI();
});
+393 -12
View File
@@ -112,6 +112,17 @@ button:disabled {
opacity: 0.48;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.topbar {
position: relative;
z-index: 30;
@@ -942,12 +953,25 @@ button:disabled {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: 42px minmax(0, 1fr) 34px auto;
grid-template-rows: minmax(0, 1fr) minmax(34px, 0fr);
overflow: hidden;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--terminal);
box-shadow: var(--shadow);
transition: grid-template-rows 220ms cubic-bezier(0.2, 0.75, 0.2, 1);
}
.terminal-card.workspace-panel-open {
grid-template-rows: minmax(0, 2fr) minmax(34px, 1fr);
}
.terminal-pane {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: 42px minmax(0, 1fr);
overflow: hidden;
}
.terminal-toolbar {
@@ -1083,9 +1107,9 @@ button:disabled {
justify-content: space-between;
gap: 12px;
padding: 0 11px;
border-top: 1px solid #1c2932;
background: #0e151c;
color: #687b87;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
color: var(--muted);
font-family: var(--mono);
font-size: 9px;
}
@@ -1096,28 +1120,352 @@ button:disabled {
white-space: nowrap;
}
.event-bar button {
.workspace-dock {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: 34px minmax(0, 1fr);
overflow: hidden;
border-top: 1px solid var(--border);
background: var(--surface);
color: var(--text);
}
.dock-tabs {
display: flex;
height: 100%;
flex: 0 0 auto;
padding: 0;
align-items: stretch;
margin-right: -11px;
}
.dock-tab {
position: relative;
min-width: 92px;
padding: 0 13px;
border: 0;
border-left: 1px solid var(--border);
background: transparent;
color: #8296a2;
color: var(--muted);
font-family: inherit;
font-size: inherit;
}
.dock-tab:hover {
background: var(--surface-3);
color: var(--text);
}
.dock-tab[aria-selected='true'] {
background: var(--surface);
color: var(--accent);
}
.dock-tab[aria-selected='true']::after {
position: absolute;
right: 10px;
bottom: 0;
left: 10px;
height: 2px;
background: var(--accent);
content: '';
}
.event-log {
max-height: 126px;
min-width: 0;
min-height: 0;
overflow: auto;
padding: 8px 11px;
border-top: 1px solid #1c2932;
background: #0b1117;
color: #718590;
padding: 9px 11px;
background: var(--surface);
color: var(--muted);
font-family: var(--mono);
font-size: 9px;
line-height: 1.7;
}
.file-manager {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr) 25px;
overflow: hidden;
background: var(--surface);
}
.file-toolbar {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
padding: 5px 8px;
border-bottom: 1px solid var(--border-soft);
background: var(--panel);
}
.file-path-field {
display: block;
min-width: 120px;
flex: 1 1 260px;
}
.file-path-field input {
width: 100%;
height: 26px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 5px;
outline: 0;
background: var(--surface);
color: var(--text);
font-family: var(--mono);
font-size: 10px;
}
.file-path-field input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-dim);
}
.file-actions {
display: flex;
min-width: 0;
flex: 0 1 auto;
align-items: center;
gap: 3px;
overflow-x: auto;
scrollbar-width: thin;
}
.file-action {
display: grid;
min-width: 27px;
height: 26px;
flex: 0 0 auto;
padding: 0 6px;
place-items: center;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
font-weight: 700;
}
.file-action-wide {
min-width: auto;
font-family: var(--sans);
font-size: 9px;
}
.file-action:hover:not(:disabled) {
border-color: var(--border);
background: var(--surface-2);
color: var(--text);
}
.file-action.danger:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
background: var(--danger-dim);
color: var(--danger);
}
.file-action-separator {
width: 1px;
height: 16px;
flex: 0 0 auto;
margin: 0 2px;
background: var(--border);
}
.file-table-wrap {
position: relative;
min-width: 0;
min-height: 0;
overflow: auto;
scrollbar-color: var(--border) transparent;
scrollbar-width: thin;
}
.file-table {
width: 100%;
min-width: 740px;
border-spacing: 0;
table-layout: fixed;
color: var(--text);
font-family: var(--mono);
font-size: 10px;
}
.file-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;
font-weight: 700;
text-align: left;
white-space: nowrap;
}
.file-table th:first-child { width: 28%; }
.file-table th:nth-child(2) { width: 10%; text-align: right; }
.file-table th:nth-child(3) { width: 10%; }
.file-table th:nth-child(4) { width: 17%; }
.file-table th:nth-child(5) { width: 14%; }
.file-table th:nth-child(6),
.file-table th:nth-child(7) { width: 10.5%; }
.file-table td {
height: 24px;
padding: 0 8px;
overflow: hidden;
border-bottom: 1px solid var(--border-soft);
text-overflow: ellipsis;
white-space: nowrap;
}
.file-table td:nth-child(2) {
color: var(--muted);
text-align: right;
}
.file-table tbody tr {
cursor: default;
}
.file-table tbody tr:hover {
background: var(--surface-2);
}
.file-table tbody tr[aria-selected='true'] {
background: var(--accent-dim);
color: var(--accent);
}
.file-name-cell {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
}
.file-name-cell span:last-child {
overflow: hidden;
text-overflow: ellipsis;
}
.file-kind-icon {
width: 15px;
flex: 0 0 15px;
color: var(--muted);
text-align: center;
}
.file-kind-icon.directory {
color: var(--warning);
}
.file-manager-state {
position: absolute;
inset: 25px 0 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
background: color-mix(in srgb, var(--surface) 92%, transparent);
color: var(--muted);
font-size: 10px;
text-align: center;
}
.file-manager-state.error {
color: var(--danger);
}
.file-spinner {
width: 13px;
height: 13px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: file-spin 750ms linear infinite;
}
.file-statusbar {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 8px;
border-top: 1px solid var(--border-soft);
background: var(--panel);
color: var(--muted-2);
font-family: var(--mono);
font-size: 8px;
}
#file-manager-status {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-progress {
display: flex;
min-width: 0;
max-width: 70%;
flex: 0 1 auto;
align-items: center;
justify-content: flex-end;
gap: 7px;
color: var(--muted);
white-space: nowrap;
}
#file-manager-progress-label {
overflow: hidden;
text-overflow: ellipsis;
}
.file-progress progress {
width: 90px;
flex: 0 1 90px;
height: 6px;
overflow: hidden;
border: 0;
border-radius: 99px;
accent-color: var(--accent);
}
.file-progress-cancel {
min-width: 42px;
height: 21px;
padding: 0 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--muted);
font-family: var(--sans);
font-size: 8px;
}
.file-progress-cancel:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
background: var(--danger-dim);
color: var(--danger);
}
.event-line {
display: grid;
grid-template-columns: 68px 120px minmax(0, 1fr);
@@ -1325,6 +1673,10 @@ button:disabled {
from { opacity: 0; transform: translateY(8px); }
}
@keyframes file-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 960px) {
.app-shell {
grid-template-columns: minmax(280px, 310px) minmax(0, 1fr);
@@ -1422,6 +1774,35 @@ button:disabled {
.event-line strong {
display: none;
}
.file-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 3px;
padding: 3px 5px;
}
.file-actions {
width: 100%;
}
.file-table {
min-width: 560px;
}
.file-table [data-column='permissions'],
.file-table [data-column='owner'],
.file-table [data-column='group'],
.file-table td:nth-child(5),
.file-table td:nth-child(6),
.file-table td:nth-child(7) {
display: none;
}
.file-table th:first-child { width: 40%; }
.file-table th:nth-child(2) { width: 16%; }
.file-table th:nth-child(3) { width: 16%; }
.file-table th:nth-child(4) { width: 28%; }
}
@media (max-width: 480px) {
+167 -7
View File
@@ -4,14 +4,25 @@ import { assertPublicTarget, toSocketHostname } from './security';
import { createTicket, verifyTicket } from './security';
import { SSHSession } from './session';
interface Attachment { phase: 'waiting' | 'connecting' | 'connected' }
interface MainAttachment { role: 'main'; phase: 'waiting' | 'connecting' | 'connected' }
interface SFTPAttachment { role: 'sftp'; phase: 'connected' }
type Attachment = MainAttachment | SFTPAttachment;
interface StoredTicket { secret: number[]; expiresAt: number; ip: string }
interface PendingConnection {
cancelled: boolean;
socket?: Socket;
closedSockets: WeakSet<Socket>;
}
interface SFTPAttachToken {
mainWebSocket: WebSocket;
attachUrl: string;
expiresAt: number;
timeout: ReturnType<typeof setTimeout>;
session?: SSHSession;
}
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;
export class SSHSessionDO implements DurableObject {
private readonly state: DurableObjectState;
@@ -19,13 +30,20 @@ export class SSHSessionDO implements DurableObject {
private readonly sessions = new Map<WebSocket, SSHSession>();
private readonly pendingConnections = new Map<WebSocket, PendingConnection>();
private readonly deadlines = new Map<WebSocket, ReturnType<typeof setTimeout>>();
private readonly sftpAttachTokens = new Map<string, SFTPAttachToken>();
private readonly sftpTokenByMainWebSocket = new Map<WebSocket, string>();
private readonly sftpSessions = new Map<WebSocket, SSHSession>();
private readonly sftpOwners = new Map<WebSocket, WebSocket>();
private readonly sftpWebSocketsByMain = 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?.phase === 'connected') {
if (attachment?.role === 'sftp') {
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 */ }
} else {
try { ws.close(1008, 'Session state expired'); } catch { /* already closed */ }
@@ -49,8 +67,15 @@ export class SSHSessionDO implements DurableObject {
secret.fill(0);
return stored ? Response.json(created) : Response.json({ error: 'Ticket already created' }, { status: 409 });
}
if (url.pathname === '/sftp') return this.attachSFTP(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');
if (!sftpAttachToken || !SFTP_ATTACH_TOKEN_PATTERN.test(sftpAttachToken)
|| !sftpAttachUrl || !this.isValidSFTPAttachUrl(sftpAttachUrl, sftpAttachToken)) {
return Response.json({ error: 'Invalid SFTP attachment authorization' }, { status: 400 });
}
const ticket = request.headers.get('x-session-ticket');
const ip = request.headers.get('x-client-ip') ?? 'unknown';
if (!ticket || !await this.consumeTicket(ticket, ip)) {
@@ -60,13 +85,29 @@ export class SSHSessionDO implements DurableObject {
const client = pair[0];
const server = pair[1];
this.state.acceptWebSocket(server);
server.serializeAttachment({ phase: 'waiting' } satisfies Attachment);
server.serializeAttachment({ role: 'main', phase: 'waiting' } satisfies MainAttachment);
this.registerSFTPAttachToken(sftpAttachToken, sftpAttachUrl, 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 sftpSession = this.sftpSessions.get(ws);
if (sftpSession || this.isSFTPWebSocket(ws)) {
try {
if (!sftpSession || ws.readyState !== WebSocket.OPEN) {
this.cleanupSFTPWebSocket(ws);
if (ws.readyState === WebSocket.OPEN) ws.close(1008, 'SFTP session is unavailable');
return;
}
await sftpSession.handleSFTPClientMessage(message);
} catch {
this.cleanupSFTPWebSocket(ws);
try { ws.close(1011, 'SFTP request failed'); } catch { /* already closed */ }
}
return;
}
try {
if (ws.readyState !== WebSocket.OPEN) {
this.cleanup(ws);
@@ -84,7 +125,7 @@ export class SSHSessionDO implements DurableObject {
const config = parseConnectMessage(decoded);
const pending: PendingConnection = { cancelled: false, closedSockets: new WeakSet() };
this.pendingConnections.set(ws, pending);
ws.serializeAttachment({ phase: 'connecting' } satisfies Attachment);
ws.serializeAttachment({ role: 'main', phase: 'connecting' } satisfies MainAttachment);
this.clearDeadline(ws);
if (config.port === 25) throw new Error('Cloudflare Workers cannot connect to outbound TCP port 25');
const verifiedAddresses = await assertPublicTarget(config.host);
@@ -97,18 +138,31 @@ export class SSHSessionDO implements DurableObject {
this.assertConnectionActive(ws, pending, socket);
const ssh = new SSHSession(ws, socket, config);
const token = this.sftpTokenByMainWebSocket.get(ws);
const sftpAttach = token ? this.sftpAttachTokens.get(token) : undefined;
if (sftpAttach) {
sftpAttach.session = ssh;
ssh.setSFTPAttachUrl(sftpAttach.attachUrl);
}
this.sessions.set(ws, ssh);
this.pendingConnections.delete(ws);
pending.socket = undefined;
ws.serializeAttachment({ phase: 'connected' } satisfies Attachment);
ws.serializeAttachment({ role: 'main', phase: 'connected' } satisfies MainAttachment);
await ssh.start();
} catch (error) {
this.reject(ws, error instanceof Error ? error.message : String(error));
}
}
async webSocketClose(ws: WebSocket): Promise<void> { this.cleanup(ws); }
async webSocketError(ws: WebSocket): Promise<void> { this.cleanup(ws); }
async webSocketClose(ws: WebSocket): Promise<void> {
if (this.isSFTPWebSocket(ws)) this.cleanupSFTPWebSocket(ws);
else this.cleanup(ws);
}
async webSocketError(ws: WebSocket): Promise<void> {
if (this.isSFTPWebSocket(ws)) this.cleanupSFTPWebSocket(ws);
else this.cleanup(ws);
}
async alarm(): Promise<void> {
await this.state.storage.delete(TICKET_STORAGE_KEY);
@@ -140,6 +194,83 @@ export class SSHSessionDO implements DurableObject {
});
}
private attachSFTP(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-sftp-attach-token');
if (!token || !SFTP_ATTACH_TOKEN_PATTERN.test(token)) {
return Response.json({ error: 'Invalid SFTP attachment token' }, { status: 401 });
}
const attach = this.sftpAttachTokens.get(token);
if (!attach || attach.expiresAt < Date.now() || attach.mainWebSocket.readyState !== WebSocket.OPEN) {
if (attach) this.deleteSFTPAttachToken(token, attach);
return Response.json({ error: 'Invalid or expired SFTP attachment token' }, { status: 401 });
}
if (!attach.session) return Response.json({ error: 'SSH session is still initializing' }, { status: 409 });
// Consume before exposing the client endpoint so concurrent replays cannot
// attach a second file-management channel to the same SSH session.
this.deleteSFTPAttachToken(token, attach);
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
this.state.acceptWebSocket(server);
server.serializeAttachment({ role: 'sftp', phase: 'connected' } satisfies SFTPAttachment);
this.sftpSessions.set(server, attach.session);
this.sftpOwners.set(server, attach.mainWebSocket);
const sockets = this.sftpWebSocketsByMain.get(attach.mainWebSocket) ?? new Set<WebSocket>();
sockets.add(server);
this.sftpWebSocketsByMain.set(attach.mainWebSocket, sockets);
try {
attach.session.attachSFTPWebSocket(server);
} catch (error) {
this.cleanupSFTPWebSocket(server);
try { server.close(1011, 'Unable to attach SFTP channel'); } catch { /* already closed */ }
console.error('Unable to attach SFTP WebSocket', error instanceof Error ? error.message : String(error));
return Response.json({ error: 'Unable to attach SFTP channel' }, { status: 500 });
}
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 = {
mainWebSocket,
attachUrl,
expiresAt,
timeout: setTimeout(() => this.deleteSFTPAttachToken(token, attach), SFTP_ATTACH_TOKEN_TTL_MS),
};
this.sftpAttachTokens.set(token, attach);
this.sftpTokenByMainWebSocket.set(mainWebSocket, token);
}
private deleteSFTPAttachToken(token: string, expected?: SFTPAttachToken): void {
const attach = this.sftpAttachTokens.get(token);
if (!attach || (expected && attach !== expected)) return;
clearTimeout(attach.timeout);
this.sftpAttachTokens.delete(token);
if (this.sftpTokenByMainWebSocket.get(attach.mainWebSocket) === token) {
this.sftpTokenByMainWebSocket.delete(attach.mainWebSocket);
}
}
private isValidSFTPAttachUrl(value: string, token: string): boolean {
if (value.length > 2048) return false;
try {
const url = new URL(value, 'https://session.invalid');
return value.startsWith('/api/sftp?')
&& url.origin === 'https://session.invalid'
&& url.pathname === '/api/sftp'
&& !url.hash
&& url.searchParams.get('token') === token
&& url.searchParams.get('session') === this.state.id.toString();
} catch {
return false;
}
}
private async openVerifiedAddress(addresses: string[], port: number, pending: PendingConnection): Promise<Socket> {
let lastError: unknown = new Error('No verified target address is available');
for (const address of addresses) {
@@ -179,6 +310,16 @@ export class SSHSessionDO implements DurableObject {
private cleanup(ws: WebSocket): void {
this.clearDeadline(ws);
const token = this.sftpTokenByMainWebSocket.get(ws);
if (token) this.deleteSFTPAttachToken(token);
const sftpWebSockets = this.sftpWebSocketsByMain.get(ws);
if (sftpWebSockets) {
for (const sftpWebSocket of [...sftpWebSockets]) {
this.cleanupSFTPWebSocket(sftpWebSocket);
try { sftpWebSocket.close(1000, 'SSH session closed'); } catch { /* already closed */ }
}
this.sftpWebSocketsByMain.delete(ws);
}
const pending = this.pendingConnections.get(ws);
if (pending) {
pending.cancelled = true;
@@ -189,6 +330,25 @@ export class SSHSessionDO implements DurableObject {
this.sessions.delete(ws);
}
private cleanupSFTPWebSocket(ws: WebSocket): void {
const session = this.sftpSessions.get(ws);
if (!session) return;
this.sftpSessions.delete(ws);
try { session.detachSFTPWebSocket(ws); } catch { /* session is already closed */ }
const mainWebSocket = this.sftpOwners.get(ws);
this.sftpOwners.delete(ws);
if (!mainWebSocket) return;
const sockets = this.sftpWebSocketsByMain.get(mainWebSocket);
sockets?.delete(ws);
if (sockets?.size === 0) this.sftpWebSocketsByMain.delete(mainWebSocket);
}
private isSFTPWebSocket(ws: WebSocket): boolean {
if (this.sftpSessions.has(ws)) return true;
const attachment = ws.deserializeAttachment() as Attachment | null;
return attachment?.role === 'sftp';
}
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);
+358 -43
View File
@@ -43,14 +43,23 @@ import { KeyDerivation } from '../ssh/keys';
import { SSHAESCTRCipher, SSHAESGCMCipher, SSHHMAC } from '../ssh/crypto';
import { SSHAuth } from '../ssh/auth';
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';
type Cipher = SSHAESGCMCipher | SSHAESCTRCipher;
type Phase = 'version' | 'kex' | 'host-confirm' | 'auth' | 'pty' | 'shell' | 'ready' | 'closed';
interface PendingSFTPChannelOpen {
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 KEEPALIVE_NAME = new TextEncoder().encode('keepalive@openssh.com');
export class SSHSession {
@@ -58,7 +67,8 @@ export class SSHSession {
private readonly decoder = new TextDecoder();
private readonly transport = new SSHTransport();
private readonly parser = new SSHPacketParser();
private readonly channel = new SSHChannel();
private readonly shellChannel = new SSHChannel();
private readonly channels = new Map<number, SSHChannel>([[0, this.shellChannel]]);
private readonly config: SSHConnectionConfig;
private readonly ws: WebSocket;
private readonly socket: Socket;
@@ -90,6 +100,14 @@ export class SSHSession {
private keyboardInteractiveRounds = 0;
private keyboardInteractivePasswordSent = false;
private pendingChannelRequest: 'pty' | 'shell' | null = null;
private nextChannelID = 1;
private sftpChannel: SSHChannel | null = null;
private sftpHandler: SFTPHandler | null = null;
private sftpWebSocket: WebSocket | null = null;
private sftpAttachUrl = '';
private sftpTaskChain: Promise<void> = Promise.resolve();
private sftpQueuedUploadBytes = 0;
private pendingSFTPChannelOpen: PendingSFTPChannelOpen | null = null;
private ignoreNextKexPacket = false;
private inputQueue: Uint8Array[] = [];
private queueHeadOffset = 0;
@@ -160,6 +178,13 @@ export class SSHSession {
this.shellTimer = null;
this.inputQueue = [];
this.queuedBytes = 0;
this.sftpHandler?.dispose();
this.sftpHandler = null;
this.sftpChannel = null;
this.clearPendingSFTPChannelOpen();
this.channels.clear();
try { this.sftpWebSocket?.close(normal ? 1000 : 1011, normal ? 'SSH session closed' : 'SSH session failed'); } catch { /* already closed */ }
this.sftpWebSocket = null;
this.pendingHostConfirmation?.resolve(false);
this.pendingHostConfirmation = null;
this.config.password = undefined;
@@ -523,7 +548,7 @@ export class SSHSession {
this.status('auth_success', 'SSH authentication succeeded; opening terminal');
this.phase = 'pty';
this.startKeepalive();
await this.sendEncrypted(this.channel.buildOpenSession(0));
await this.sendEncrypted(this.shellChannel.buildOpenSession(0));
return;
}
if (type === SSH_MSG_USERAUTH_FAILURE) {
@@ -543,60 +568,132 @@ export class SSHSession {
}
private async handleChannel(type: number, payload: Uint8Array): Promise<void> {
if (payload.length < 5) throw new Error('Malformed SSH channel message');
const channelID = readUint32(payload, 1);
const channel = this.channels.get(channelID);
if (!channel) throw new Error('SSH channel message has an unknown recipient');
const isShell = channel === this.shellChannel;
if (type === SSH_MSG_CHANNEL_OPEN_CONFIRMATION) {
if (this.phase !== 'pty') throw new Error('Unexpected channel open confirmation');
this.channel.handleOpenConfirmation(payload);
this.pendingChannelRequest = 'pty';
await this.sendEncrypted(this.channel.buildPTYRequest(this.config.cols, this.config.rows, this.config.term));
channel.handleOpenConfirmation(payload);
if (isShell) {
if (this.phase !== 'pty') throw new Error('Unexpected shell channel open confirmation');
this.pendingChannelRequest = 'pty';
await this.sendEncrypted(channel.buildPTYRequest(this.config.cols, this.config.rows, this.config.term));
} else if (channel === this.sftpChannel && this.sftpHandler && !this.pendingSFTPChannelOpen?.cancelled) {
try {
await this.sendEncrypted(channel.buildSubsystemRequest('sftp'));
} catch (error) {
this.clearPendingSFTPChannelOpen(channel);
this.sftpHandler.dispose();
this.sftpHandler = null;
this.sftpChannel = null;
await this.sendSFTPChannelClose(channel);
throw error;
}
} else {
// The file WebSocket may close while the channel open is in flight.
this.clearPendingSFTPChannelOpen(channel);
await this.sendSFTPChannelClose(channel);
}
return;
}
if (type === SSH_MSG_CHANNEL_OPEN_FAILURE) {
this.channel.handleOpenFailure(payload);
throw new Error('SSH server rejected the session channel');
channel.handleOpenFailure(payload);
this.clearPendingSFTPChannelOpen(channel);
this.channels.delete(channelID);
if (isShell) throw new Error('SSH server rejected the session channel');
if (channel === this.sftpChannel) {
this.sftpHandler?.dispose();
this.sftpHandler = null;
this.sftpChannel = null;
this.sendSFTPError('init', 'SSH server rejected the SFTP channel');
}
return;
}
if (type === SSH_MSG_CHANNEL_SUCCESS || type === SSH_MSG_CHANNEL_FAILURE) {
this.channel.handleRequestResult(payload);
const pending = this.pendingChannelRequest;
if (!pending || pending !== this.phase) throw new Error('Unexpected SSH channel request result');
this.pendingChannelRequest = null;
if (type === SSH_MSG_CHANNEL_FAILURE) throw new Error(`SSH server rejected the ${pending === 'pty' ? 'PTY' : 'shell'} request`);
if (pending === 'pty') {
this.phase = 'shell';
this.pendingChannelRequest = 'shell';
await this.sendEncrypted(this.channel.buildShellRequest());
this.shellTimer = setTimeout(() => this.markReady(), 3000);
} else this.markReady();
channel.handleRequestResult(payload);
this.clearPendingSFTPChannelOpen(channel);
if (isShell) {
const pending = this.pendingChannelRequest;
if (!pending || pending !== this.phase) throw new Error('Unexpected SSH channel request result');
this.pendingChannelRequest = null;
if (type === SSH_MSG_CHANNEL_FAILURE) throw new Error(`SSH server rejected the ${pending === 'pty' ? 'PTY' : 'shell'} request`);
if (pending === 'pty') {
this.phase = 'shell';
this.pendingChannelRequest = 'shell';
await this.sendEncrypted(channel.buildShellRequest());
this.shellTimer = setTimeout(() => this.markReady(), 3000);
} else this.markReady();
} else if (channel === this.sftpChannel && type === SSH_MSG_CHANNEL_SUCCESS) {
const handler = this.sftpHandler;
if (handler) {
void handler.initialize().then((ready) => {
if (!ready && this.sftpHandler === handler) void this.closeSFTPChannel();
});
}
} else if (channel === this.sftpChannel) {
this.sftpHandler?.dispose();
this.sftpHandler = null;
this.sftpChannel = null;
this.sendSFTPError('init', 'SSH server rejected the SFTP subsystem');
await this.sendSFTPChannelClose(channel);
}
return;
}
if (type === SSH_MSG_CHANNEL_DATA) {
if (this.phase === 'shell') this.markReady();
const output = this.channel.handleChannelData(payload);
this.ws.send(output);
await this.adjustLocalWindow();
const output = channel.handleChannelData(payload);
if (isShell) {
if (this.phase === 'shell') this.markReady();
this.ws.send(output);
} else if (channel === this.sftpChannel && this.sftpHandler) {
try {
this.sftpHandler.feed(output);
} catch (error) {
this.sendSFTPError('protocol', error instanceof Error ? error.message : String(error));
await this.closeSFTPChannel();
}
}
await this.adjustLocalWindow(channel);
return;
}
if (type === SSH_MSG_CHANNEL_EXTENDED_DATA) {
if (this.phase === 'shell') this.markReady();
const output = this.channel.handleExtendedData(payload);
this.ws.send(output);
await this.adjustLocalWindow();
const output = channel.handleExtendedData(payload);
if (isShell) {
if (this.phase === 'shell') this.markReady();
this.ws.send(output);
} else if (channel === this.sftpChannel) {
this.sendSFTPError('protocol', new TextDecoder().decode(output) || 'SFTP channel reported an error');
}
await this.adjustLocalWindow(channel);
return;
}
if (type === SSH_MSG_CHANNEL_WINDOW_ADJUST) {
this.channel.handleWindowAdjust(payload);
void this.flushInput();
channel.handleWindowAdjust(payload);
if (isShell) void this.flushInput();
else if (channel === this.sftpChannel) this.sftpHandler?.onWindowAdjust();
return;
}
if (type === SSH_MSG_CHANNEL_EOF) {
this.channel.handleEof(payload);
this.status('remote_eof', 'SSH server finished sending output');
channel.handleEof(payload);
if (isShell) this.status('remote_eof', 'SSH server finished sending output');
else if (channel === this.sftpChannel) this.sftpHandler?.onClosed();
return;
}
if (type === SSH_MSG_CHANNEL_CLOSE) {
this.channel.handleClose(payload);
if (!this.channel.hasSentClose()) await this.sendEncrypted(this.channel.buildClose());
this.status('session_ended', 'SSH session ended');
this.close(true);
channel.handleClose(payload);
if (!channel.hasSentClose()) await this.sendEncrypted(channel.buildClose());
if (isShell) {
this.status('session_ended', 'SSH session ended');
this.close(true);
} else {
this.clearPendingSFTPChannelOpen(channel);
this.channels.delete(channelID);
if (channel === this.sftpChannel) {
this.sftpHandler?.onClosed();
this.sftpHandler = null;
this.sftpChannel = null;
}
}
return;
}
}
@@ -612,9 +709,227 @@ export class SSHSession {
negotiated: { kex: this.kexName, cipherC2S: this.cipherC2S, cipherS2C: this.cipherS2C, macC2S: this.macC2S, macS2C: this.macS2C },
});
this.status('shell_ready', 'Shell is ready');
if (this.sftpAttachUrl) this.sendJson({ type: 'sftp_attach', url: this.sftpAttachUrl });
void this.flushInput();
}
setSFTPAttachUrl(url: string): void {
if (!/^\/api\/sftp\?/.test(url)) throw new Error('Invalid SFTP attach URL');
this.sftpAttachUrl = url;
}
attachSFTPWebSocket(ws: WebSocket): void {
if (this.phase === 'closed' || this.sftpWebSocket) {
try { ws.close(1008, 'SFTP connection is unavailable'); } catch { /* already closed */ }
return;
}
this.sftpWebSocket = ws;
}
detachSFTPWebSocket(ws: WebSocket): void {
if (this.sftpWebSocket !== ws) return;
this.sftpWebSocket = null;
void this.closeSFTPChannel();
}
async handleSFTPClientMessage(message: string | ArrayBuffer): Promise<void> {
if (this.phase === 'closed' || !this.sftpWebSocket) return;
if (message instanceof ArrayBuffer) {
if (message.byteLength === 0 || message.byteLength > 64 * 1024) {
this.sendSFTPError('upload', 'Invalid upload chunk');
return;
}
if (this.sftpQueuedUploadBytes + message.byteLength > MAX_QUEUED_SFTP_UPLOAD) {
this.sendSFTPError('upload', 'Upload queue limit exceeded');
return;
}
const chunk = new Uint8Array(message);
this.sftpQueuedUploadBytes += chunk.byteLength;
await this.queueSFTPTask('upload', undefined, async () => {
try {
if (!this.sftpHandler) throw new Error('SFTP is not initialized');
await this.sftpHandler.uploadChunk(chunk);
} finally {
this.sftpQueuedUploadBytes = Math.max(0, this.sftpQueuedUploadBytes - chunk.byteLength);
}
});
return;
}
if (message.length > 16 * 1024) {
this.sendSFTPError('protocol', 'SFTP control message is too large');
return;
}
let value: Record<string, unknown>;
try {
const parsed = JSON.parse(message) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error();
value = parsed as Record<string, unknown>;
} catch {
this.sendSFTPError('protocol', 'Invalid SFTP control message');
return;
}
if (value.type === 'ping') { this.sendSFTPJson({ type: 'pong' }); return; }
if (value.type === 'sftp_download_cancel') {
try {
const requestId = this.requestId(value.requestId);
if (!this.sftpHandler) throw new Error('SFTP is not initialized');
this.sftpHandler.cancelDownload(requestId);
} catch (error) {
this.sendSFTPError('download', error instanceof Error ? error.message : String(error));
}
return;
}
const operation = typeof value.type === 'string' ? value.type.replace(/^sftp_/, '') : 'protocol';
await this.queueSFTPTask(operation, this.requestId(value.requestId, false), () => this.handleSFTPControl(value));
}
private async handleSFTPControl(value: Record<string, unknown>): Promise<void> {
const type = typeof value.type === 'string' ? value.type : '';
if (type === 'sftp_close') { await this.closeSFTPChannel(); return; }
if (this.phase !== 'ready') throw new Error('SSH connection is not ready');
if (type === 'sftp_init') {
await this.openSFTPChannel();
return;
}
if (!this.sftpHandler) throw new Error('SFTP is not initialized');
const requestId = this.requestId(value.requestId);
if (type === 'sftp_list') return this.sftpHandler.list(requestId, this.path(value.path));
if (type === 'sftp_download') return this.sftpHandler.download(requestId, this.path(value.path));
if (type === 'sftp_upload_start') {
if (typeof value.size !== 'number') throw new Error('Invalid upload size');
if (typeof value.overwrite !== 'boolean') throw new Error('Invalid upload overwrite option');
return this.sftpHandler.startUpload(requestId, this.path(value.path), value.size, value.overwrite);
}
if (type === 'sftp_upload_end') return this.sftpHandler.finishUpload(requestId);
if (type === 'sftp_upload_cancel') return this.sftpHandler.cancelUpload(requestId);
if (type === 'sftp_delete') return this.sftpHandler.remove(requestId, this.path(value.path), false);
if (type === 'sftp_rmdir') return this.sftpHandler.remove(requestId, this.path(value.path), true);
if (type === 'sftp_mkdir') return this.sftpHandler.mkdir(requestId, this.path(value.path));
if (type === 'sftp_rename') return this.sftpHandler.rename(requestId, this.path(value.oldPath), this.path(value.newPath));
throw new Error('Unsupported SFTP control message');
}
private async openSFTPChannel(): Promise<void> {
if (!this.sftpWebSocket) throw new Error('SFTP WebSocket is not attached');
if (this.pendingSFTPChannelOpen?.cancelled) throw new Error('The previous SFTP channel is still closing');
if (this.sftpHandler) {
if (this.sftpHandler.isReady()) this.sftpHandler.announceReady();
else this.sendSFTPError('init', 'SFTP initialization is already in progress');
return;
}
const channelID = this.nextChannelID++;
const channel = new SSHChannel();
this.channels.set(channelID, channel);
this.sftpChannel = channel;
this.sftpHandler = new SFTPHandler(
channel,
(target, chunk) => this.sendChannelData(target, chunk),
(message) => this.sendSFTPJson(message),
(data) => this.sendSFTPBinary(data),
);
const pending: PendingSFTPChannelOpen = {
channelID,
channel,
cancelled: false,
timeout: null,
};
this.pendingSFTPChannelOpen = pending;
try {
await this.sendEncrypted(channel.buildOpenSession(channelID));
if (this.pendingSFTPChannelOpen === pending) {
pending.timeout = setTimeout(() => this.expirePendingSFTPChannelOpen(pending), SFTP_CHANNEL_OPEN_TIMEOUT_MS);
}
} catch (error) {
this.clearPendingSFTPChannelOpen(channel);
this.channels.delete(channelID);
this.sftpHandler.dispose();
this.sftpHandler = null;
this.sftpChannel = null;
throw error;
}
}
private async closeSFTPChannel(): Promise<void> {
const handler = this.sftpHandler;
if (!handler) return;
const channel = this.sftpChannel;
const pending = this.pendingSFTPChannelOpen;
if (pending?.channel === channel) pending.cancelled = true;
this.sftpHandler = null;
this.sftpChannel = null;
handler.dispose();
if (!channel) return;
if (!channel.isOpen()) return;
await this.sendSFTPChannelClose(channel);
}
private async queueSFTPTask(operation: string, requestId: string | undefined, task: () => Promise<void>): Promise<void> {
const run = this.sftpTaskChain.then(task);
const handled = run.catch((error) => {
this.sendSFTPError(operation, error instanceof Error ? error.message : String(error), requestId);
});
this.sftpTaskChain = handled;
await handled;
}
private expirePendingSFTPChannelOpen(expected: PendingSFTPChannelOpen): void {
if (this.pendingSFTPChannelOpen !== expected) return;
this.pendingSFTPChannelOpen = null;
expected.timeout = null;
expected.cancelled = true;
if (this.sftpChannel === expected.channel) {
this.sftpHandler?.dispose();
this.sftpHandler = null;
this.sftpChannel = null;
}
this.sendSFTPError('init', 'Timed out opening the SFTP channel');
if (expected.channel.isOpen()) void this.sendSFTPChannelClose(expected.channel);
try { this.sftpWebSocket?.close(1011, 'SFTP channel setup timed out'); } catch { /* already closed */ }
}
private async sendSFTPChannelClose(channel: SSHChannel): Promise<void> {
if (!channel.isOpen() || channel.hasSentClose()) return;
try {
await this.sendEncrypted(channel.buildEof());
await this.sendEncrypted(channel.buildClose());
} catch { /* The SSH session may already be closing. */ }
}
private clearPendingSFTPChannelOpen(channel?: SSHChannel): void {
const pending = this.pendingSFTPChannelOpen;
if (!pending || (channel && pending.channel !== channel)) return;
if (pending.timeout) clearTimeout(pending.timeout);
pending.timeout = null;
this.pendingSFTPChannelOpen = null;
}
private requestId(value: unknown): string;
private requestId(value: unknown, required: true): string;
private requestId(value: unknown, required: false): string | undefined;
private requestId(value: unknown, required = true): string | undefined {
if (typeof value === 'string' && /^[A-Za-z0-9._:-]{1,64}$/.test(value)) return value;
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return String(value);
if (!required) return undefined;
throw new Error('Invalid SFTP request identifier');
}
private path(value: unknown): string {
if (typeof value !== 'string') throw new Error('Invalid remote path');
return value;
}
private sendSFTPJson(value: unknown): void {
if (this.sftpWebSocket?.readyState === WebSocket.OPEN) this.sftpWebSocket.send(JSON.stringify(value));
}
private sendSFTPBinary(data: Uint8Array): void {
if (this.sftpWebSocket?.readyState === WebSocket.OPEN) this.sftpWebSocket.send(data);
}
private sendSFTPError(operation: string, message: string, requestId?: string): void {
this.sendSFTPJson({ type: 'sftp_error', operation, message, requestId });
}
private queueInput(data: Uint8Array): void {
if (data.length === 0) return;
if (this.phase !== 'ready') throw new Error('Terminal is not ready');
@@ -630,9 +945,9 @@ export class SSHSession {
try {
while (this.inputQueue.length > 0 && this.phase === 'ready') {
const first = this.inputQueue[0];
const chunk = this.channel.takeChannelDataChunk(first, this.queueHeadOffset);
const chunk = this.shellChannel.takeChannelDataChunk(first, this.queueHeadOffset);
if (!chunk) return;
await this.sendChannelData(chunk);
await this.sendChannelData(this.shellChannel, chunk);
this.queueHeadOffset += chunk.bytesConsumed;
this.queuedBytes -= chunk.bytesConsumed;
if (this.queueHeadOffset === first.length) {
@@ -647,12 +962,12 @@ export class SSHSession {
private async resize(cols: unknown, rows: unknown): Promise<void> {
if (typeof cols !== 'number' || typeof rows !== 'number' || !Number.isInteger(cols) || !Number.isInteger(rows) || cols < 10 || cols > 1000 || rows < 5 || rows > 1000) throw new Error('Invalid terminal size');
if (this.phase === 'ready') await this.sendEncrypted(this.channel.buildWindowChange(cols, rows));
if (this.phase === 'ready') await this.sendEncrypted(this.shellChannel.buildWindowChange(cols, rows));
}
private async adjustLocalWindow(): Promise<void> {
const amount = this.channel.takeLocalWindowAdjustment(LOCAL_WINDOW_THRESHOLD);
if (amount !== null) await this.sendEncrypted(this.channel.buildWindowAdjust(amount));
private async adjustLocalWindow(channel: SSHChannel): Promise<void> {
const amount = channel.takeLocalWindowAdjustment(LOCAL_WINDOW_THRESHOLD);
if (amount !== null) await this.sendEncrypted(channel.buildWindowAdjust(amount));
}
private async handleGlobalRequest(payload: Uint8Array): Promise<void> {
@@ -701,11 +1016,11 @@ export class SSHSession {
});
}
private async sendChannelData(chunk: ChannelDataChunk): Promise<void> {
private async sendChannelData(channel: SSHChannel, chunk: ChannelDataChunk): Promise<void> {
await this.serialSend(async () => {
if (!this.encryptor) throw new Error('SSH encryption is not initialized');
const spec = getCipherSpec(this.cipherC2S);
const packet = await SSHPacketBuilder.buildWithPayloadWriter(chunk.payloadLength, (target, offset) => this.channel.writeChannelDataPayload(target, offset, chunk.source, chunk.sourceOffset, chunk.bytesConsumed), spec.blockSize, (data, sequence, aad) => this.encryptor!.encrypt(data, sequence, aad), this.sendSequence, spec.aead, this.signer ? (data, sequence) => this.signer!.sign(data, sequence) : undefined);
const packet = await SSHPacketBuilder.buildWithPayloadWriter(chunk.payloadLength, (target, offset) => channel.writeChannelDataPayload(target, offset, chunk.source, chunk.sourceOffset, chunk.bytesConsumed), spec.blockSize, (data, sequence, aad) => this.encryptor!.encrypt(data, sequence, aad), this.sendSequence, spec.aead, this.signer ? (data, sequence) => this.signer!.sign(data, sequence) : undefined);
this.sendSequence = nextSequenceNumber(this.sendSequence);
await this.write(packet);
});
+396
View File
@@ -0,0 +1,396 @@
import { SSHChannel, type ChannelDataChunk } from '../ssh/channel';
import {
SFTPClient,
SFTPStatusError,
SFTP_OPEN_FLAGS,
getSFTPFileKind,
formatSFTPPermissions,
type SFTPAttrs,
type SFTPDirEntry,
} from '../ssh/sftp';
const MAX_PATH_BYTES = 4096;
const MAX_DIRECTORY_ENTRIES = 2000;
const MAX_DIRECTORY_RESPONSE_BYTES = 2 * 1024 * 1024;
const MAX_FILENAME_BYTES = 1024;
const MAX_FILE_SIZE = 64 * 1024 * 1024;
const TRANSFER_CHUNK_SIZE = 64 * 1024;
type SendChannelData = (channel: SSHChannel, chunk: ChannelDataChunk) => Promise<void>;
type SendJSON = (message: unknown) => void;
type SendBinary = (data: Uint8Array) => void;
interface UploadState {
requestId: string;
destinationPath: string;
uploadPath: string;
size: number;
received: number;
handle: Uint8Array;
handleClosed: boolean;
temporary: boolean;
}
interface DownloadState {
requestId: string;
cancelled: boolean;
}
export class SFTPHandler {
private readonly channel: SSHChannel;
private readonly client: SFTPClient;
private readonly sendChannelData: SendChannelData;
private readonly sendJSON: SendJSON;
private readonly sendBinary: SendBinary;
private readonly sendQueue: Array<{ data: Uint8Array; offset: number }> = [];
private flushing = false;
private ready = false;
private disposed = false;
private activeDownload: DownloadState | null = null;
private upload: UploadState | null = null;
constructor(channel: SSHChannel, sendChannelData: SendChannelData, sendJSON: SendJSON, sendBinary: SendBinary) {
this.channel = channel;
this.sendChannelData = sendChannelData;
this.sendJSON = sendJSON;
this.sendBinary = sendBinary;
this.client = new SFTPClient((data: Uint8Array) => this.enqueue(data));
}
isReady(): boolean {
return this.ready;
}
async initialize(): Promise<boolean> {
try {
const versionInfo = await this.client.initialize();
const version = versionInfo.version;
if (version !== 3) throw new Error(`Unsupported SFTP version ${version}`);
this.ready = true;
let cwd = '.';
try { cwd = await this.client.realpath('.'); } catch { /* Listing will surface any path error. */ }
this.sendJSON({ type: 'sftp_ready', version, cwd });
return true;
} catch (error) {
this.reportError('init', error);
return false;
}
}
announceReady(): void {
if (this.ready) this.sendJSON({ type: 'sftp_ready', version: 3 });
}
feed(data: Uint8Array): void {
this.client.feed(data);
}
onWindowAdjust(): void {
void this.flush();
}
onClosed(message = 'SFTP channel closed'): void {
this.ready = false;
this.sendJSON({ type: 'sftp_closed', message });
this.dispose();
}
async list(requestId: string, path: string): Promise<void> {
try {
this.assertReady();
this.validatePath(path);
const resolvedPath = await this.client.realpath(path || '.');
const handle = await this.client.openDir(resolvedPath);
const entries: Record<string, unknown>[] = [];
let responseBytes = 0;
let isTruncated = false;
try {
while (entries.length < MAX_DIRECTORY_ENTRIES) {
const batch = await this.client.readDir(handle);
if (batch === null || batch.length === 0) break;
for (const entry of batch) {
if (entry.filename === '.' || entry.filename === '..') continue;
if (entries.length === MAX_DIRECTORY_ENTRIES) { isTruncated = true; break; }
const filenameBytes = new TextEncoder().encode(entry.filename).length;
if (filenameBytes < 1 || filenameBytes > MAX_FILENAME_BYTES || entry.filename.includes('\0')) {
throw new Error('SFTP server returned an invalid filename');
}
const formatted = this.formatEntry(entry);
const estimatedBytes = filenameBytes + String(formatted.owner ?? '').length + String(formatted.group ?? '').length + 256;
if (responseBytes + estimatedBytes > MAX_DIRECTORY_RESPONSE_BYTES) { isTruncated = true; break; }
responseBytes += estimatedBytes;
entries.push(formatted);
}
if (isTruncated) break;
}
if (entries.length === MAX_DIRECTORY_ENTRIES) isTruncated = true;
} finally {
await this.client.closeHandle(handle).catch(() => undefined);
}
this.sendJSON({
type: 'sftp_list_result', requestId, path: resolvedPath, isTruncated,
entries,
});
} catch (error) {
this.reportError('list', error, requestId);
}
}
async download(requestId: string, path: string): Promise<void> {
let handle: Uint8Array | null = null;
const download: DownloadState = { requestId, cancelled: false };
try {
this.assertReady();
this.validatePath(path);
if (this.activeDownload) throw new Error('Another download is already in progress');
this.activeDownload = download;
const attrs = await this.client.stat(path);
this.assertDownloadActive(download);
const size = this.safeSize(attrs);
if (size !== null && size > MAX_FILE_SIZE) throw new Error('File exceeds the 64 MiB transfer limit');
handle = await this.client.openFile(path, SFTP_OPEN_FLAGS.READ);
this.assertDownloadActive(download);
const filename = path.split('/').filter(Boolean).pop() || 'download';
this.sendJSON(size === null
? { type: 'sftp_download_start', requestId, filename }
: { type: 'sftp_download_start', requestId, filename, size });
let offset = 0;
while (size === null || offset < size) {
this.assertDownloadActive(download);
const length = size === null ? TRANSFER_CHUNK_SIZE : Math.min(TRANSFER_CHUNK_SIZE, size - offset);
if (length === 0) break;
const chunk = await this.client.readFile(handle, offset, length);
this.assertDownloadActive(download);
if (chunk === null || chunk.length === 0) break;
if (offset + chunk.length > MAX_FILE_SIZE) throw new Error('File exceeds the 64 MiB transfer limit');
this.sendBinary(chunk);
offset += chunk.length;
this.sendJSON(size === null
? { type: 'sftp_download_progress', requestId, loaded: offset }
: { type: 'sftp_download_progress', requestId, loaded: offset, total: size });
}
this.assertDownloadActive(download);
this.sendJSON({ type: 'sftp_download_done', requestId, filename, size: offset });
} catch (error) {
if (download.cancelled) this.sendJSON({ type: 'sftp_download_cancelled', requestId });
else this.reportError('download', error, requestId);
} finally {
if (handle) await this.client.closeHandle(handle).catch(() => undefined);
if (this.activeDownload === download) this.activeDownload = null;
}
}
cancelDownload(requestId: string): void {
if (this.activeDownload?.requestId === requestId) this.activeDownload.cancelled = true;
}
async startUpload(requestId: string, path: string, size: number, overwrite: boolean): Promise<void> {
try {
this.assertReady();
this.validatePath(path);
if (this.upload) throw new Error('Another upload is already in progress');
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_FILE_SIZE) throw new Error('Invalid upload size or file exceeds the 64 MiB transfer limit');
const uploadPath = overwrite ? this.temporaryUploadPath(path) : path;
const handle = await this.client.openFile(
uploadPath,
SFTP_OPEN_FLAGS.WRITE | SFTP_OPEN_FLAGS.CREATE | SFTP_OPEN_FLAGS.TRUNCATE | SFTP_OPEN_FLAGS.EXCLUSIVE,
);
this.upload = {
requestId,
destinationPath: path,
uploadPath,
size,
received: 0,
handle,
handleClosed: false,
temporary: overwrite,
};
this.sendJSON({ type: 'sftp_upload_ready', requestId, path, size });
} catch (error) {
this.reportError('upload', error, requestId);
}
}
async uploadChunk(data: Uint8Array): Promise<void> {
const upload = this.upload;
if (!upload) throw new Error('No upload is in progress');
if (data.length === 0 || data.length > TRANSFER_CHUNK_SIZE || upload.received + data.length > upload.size) {
const requestId = upload.requestId;
await this.abortUpload(upload, true);
this.reportError('upload', new Error('Invalid upload chunk'), requestId);
return;
}
try {
await this.client.writeFile(upload.handle, upload.received, data);
upload.received += data.length;
this.sendJSON({ type: 'sftp_upload_progress', requestId: upload.requestId, loaded: upload.received, total: upload.size });
} catch (error) {
const requestId = upload.requestId;
await this.abortUpload(upload, true);
this.reportError('upload', error, requestId);
}
}
async finishUpload(requestId: string): Promise<void> {
const upload = this.upload;
try {
if (!upload || upload.requestId !== requestId) throw new Error('No matching upload is in progress');
if (upload.received !== upload.size) throw new Error('Upload ended before the declared size was received');
await this.client.closeHandle(upload.handle);
upload.handleClosed = true;
if (upload.temporary) await this.client.rename(upload.uploadPath, upload.destinationPath);
this.upload = null;
this.sendJSON({ type: 'sftp_upload_complete', requestId, path: upload.destinationPath, size: upload.received });
} catch (error) {
if (upload) await this.abortUpload(upload, true);
this.reportError('upload', error, requestId);
}
}
async cancelUpload(requestId: string): Promise<void> {
const upload = this.upload;
if (!upload || upload.requestId !== requestId) return;
await this.abortUpload(upload, true);
this.sendJSON({ type: 'sftp_upload_cancelled', requestId });
}
async remove(requestId: string, path: string, directory: boolean): Promise<void> {
const operation = directory ? 'rmdir' : 'delete';
try {
this.assertReady();
this.validatePath(path);
if (directory) await this.client.rmdir(path);
else await this.client.removeFile(path);
this.sendJSON({ type: `sftp_${operation}_result`, requestId, path, success: true });
} catch (error) {
this.reportError(operation, error, requestId);
}
}
async mkdir(requestId: string, path: string): Promise<void> {
try {
this.assertReady();
this.validatePath(path);
await this.client.mkdir(path, { permissions: 0o755 });
this.sendJSON({ type: 'sftp_mkdir_result', requestId, path, success: true });
} catch (error) {
this.reportError('mkdir', error, requestId);
}
}
async rename(requestId: string, oldPath: string, newPath: string): Promise<void> {
try {
this.assertReady();
this.validatePath(oldPath);
this.validatePath(newPath);
await this.client.rename(oldPath, newPath);
this.sendJSON({ type: 'sftp_rename_result', requestId, oldPath, newPath, success: true });
} catch (error) {
this.reportError('rename', error, requestId);
}
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.ready = false;
this.sendQueue.length = 0;
this.client.dispose();
}
private enqueue(data: Uint8Array): void {
if (this.disposed) return;
this.sendQueue.push({ data, offset: 0 });
void this.flush();
}
private async flush(): Promise<void> {
if (this.flushing || this.disposed) return;
this.flushing = true;
try {
while (this.sendQueue.length > 0 && !this.disposed) {
const current = this.sendQueue[0];
const chunk = this.channel.takeChannelDataChunk(current.data, current.offset);
if (!chunk) return;
await this.sendChannelData(this.channel, chunk);
current.offset += chunk.bytesConsumed;
if (current.offset === current.data.length) this.sendQueue.shift();
}
} catch (error) {
this.reportError('protocol', error);
} finally {
this.flushing = false;
}
}
private async abortUpload(upload: UploadState, removePartial: boolean): Promise<void> {
if (this.upload === upload) this.upload = null;
if (!upload.handleClosed) {
await this.client.closeHandle(upload.handle).catch(() => undefined);
upload.handleClosed = true;
}
if (removePartial) await this.client.removeFile(upload.uploadPath).catch(() => undefined);
}
private assertReady(): void {
if (!this.ready || this.disposed) throw new Error('SFTP is not ready');
}
private validatePath(path: string): void {
const length = new TextEncoder().encode(path).length;
if (length < 1 || length > MAX_PATH_BYTES || path.includes('\0')) throw new Error('Invalid remote path');
}
private safeSize(attrs: SFTPAttrs): number | null {
if (attrs.size === undefined) return null;
if (typeof attrs.size === 'string') throw new Error('File is too large for this client');
const size = attrs.size;
if (!Number.isSafeInteger(size) || size < 0) throw new Error('Invalid remote file size');
return size;
}
private assertDownloadActive(download: DownloadState): void {
if (this.activeDownload !== download || download.cancelled) throw new Error('Download cancelled');
}
private temporaryUploadPath(path: string): string {
const separator = path.lastIndexOf('/');
const parent = separator >= 0 ? path.slice(0, separator + 1) : '';
const suffix = crypto.randomUUID().replaceAll('-', '');
const temporary = `${parent}.cf-webssh-upload-${suffix}`;
this.validatePath(temporary);
return temporary;
}
private formatEntry(entry: SFTPDirEntry): Record<string, unknown> {
const attrs = entry.attrs;
const kind = getSFTPFileKind(attrs);
const type = kind === 'directory' || kind === 'symlink' ? kind : kind === 'file' ? 'file' : 'other';
const identity = this.parseLongnameIdentity(entry.longname);
return {
name: entry.filename,
type,
size: attrs.size ?? 0,
mtime: attrs.mtime ?? 0,
permissions: formatSFTPPermissions(attrs),
permissionsRaw: attrs.permissions ?? 0,
uid: attrs.uid,
gid: attrs.gid,
owner: identity.owner,
group: identity.group,
};
}
private parseLongnameIdentity(longname: string): { owner?: string; group?: string } {
if (longname.length > 4096) return {};
const fields = longname.trim().split(/\s+/);
if (fields.length < 4 || !/^[bcdlps-][rwxStTs-]{9}[+@.]?$/.test(fields[0])) return {};
return { owner: fields[2], group: fields[3] };
}
private reportError(operation: string, error: unknown, requestId?: string): void {
const message = error instanceof Error ? error.message : String(error);
const code = error instanceof SFTPStatusError ? error.code : undefined;
this.sendJSON({ type: 'sftp_error', requestId, operation, code, message });
}
}
+38
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 SUBSYSTEM_FIELD = encodeString('subsystem');
const WINDOW_CHANGE_FIELD = encodeString('window-change');
const EMPTY_TERMINAL_MODES_FIELD = encodeString(new Uint8Array([0]));
const UINT32_MAX = 0xffffffff;
@@ -37,6 +38,7 @@ export class SSHChannel {
private remoteMaxPacketSize: number = DEFAULT_MAX_PACKET_SIZE;
private consumedSinceWindowAdjust: number = 0;
private eofReceived: boolean = false;
private eofSent: boolean = false;
private closeSent: boolean = false;
private closeReceived: boolean = false;
private openConfirmed: boolean = false;
@@ -45,6 +47,19 @@ export class SSHChannel {
return this.closeSent && this.closeReceived;
}
getLocalChannelID(): number {
return this.localChannelID;
}
getRemoteChannelID(): number {
if (!this.openConfirmed) throw new Error('Channel is not open');
return this.remoteChannelID;
}
isOpen(): boolean {
return this.openConfirmed && !this.closeReceived;
}
hasSentClose(): boolean {
return this.closeSent;
}
@@ -130,6 +145,20 @@ export class SSHChannel {
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);
const payload = new Uint8Array(1 + 4 + SUBSYSTEM_FIELD.length + 1 + subsystemField.length);
let offset = 0;
payload[offset++] = SSH_MSG_CHANNEL_REQUEST;
writeUint32(payload, offset, this.getRemoteChannelID());
offset += 4;
offset = writeBytes(payload, offset, SUBSYSTEM_FIELD);
payload[offset++] = 0x01;
writeBytes(payload, offset, subsystemField);
return payload;
}
buildClose(): Uint8Array {
if (this.closeSent) throw new Error('Channel close was already sent');
this.closeSent = true;
@@ -139,6 +168,15 @@ export class SSHChannel {
return payload;
}
buildEof(): Uint8Array {
if (this.eofSent) throw new Error('Channel EOF was already sent');
this.eofSent = true;
const payload = new Uint8Array(5);
payload[0] = SSH_MSG_CHANNEL_EOF;
writeUint32(payload, 1, this.getRemoteChannelID());
return payload;
}
takeChannelDataChunk(data: Uint8Array, offset: number = 0): ChannelDataChunk | null {
const bytesAvailable = data.length - offset;
if (bytesAvailable <= 0) {
+197
View File
@@ -0,0 +1,197 @@
/** SFTP protocol version implemented by this client. */
export const SFTP_VERSION = 3;
export const SFTP_PACKET_TYPES = {
INIT: 1,
VERSION: 2,
OPEN: 3,
CLOSE: 4,
READ: 5,
WRITE: 6,
LSTAT: 7,
FSTAT: 8,
SETSTAT: 9,
FSETSTAT: 10,
OPENDIR: 11,
READDIR: 12,
REMOVE: 13,
MKDIR: 14,
RMDIR: 15,
REALPATH: 16,
STAT: 17,
RENAME: 18,
READLINK: 19,
SYMLINK: 20,
STATUS: 101,
HANDLE: 102,
DATA: 103,
NAME: 104,
ATTRS: 105,
EXTENDED: 200,
EXTENDED_REPLY: 201,
} as const;
export const SFTP_STATUS = {
OK: 0,
EOF: 1,
NO_SUCH_FILE: 2,
PERMISSION_DENIED: 3,
FAILURE: 4,
BAD_MESSAGE: 5,
NO_CONNECTION: 6,
CONNECTION_LOST: 7,
OP_UNSUPPORTED: 8,
} as const;
export const SFTP_OPEN_FLAGS = {
READ: 0x00000001,
WRITE: 0x00000002,
APPEND: 0x00000004,
CREATE: 0x00000008,
TRUNCATE: 0x00000010,
EXCLUSIVE: 0x00000020,
} as const;
export const SFTP_ATTR_FLAGS = {
SIZE: 0x00000001,
UID_GID: 0x00000002,
PERMISSIONS: 0x00000004,
ACCESS_MODIFY_TIME: 0x00000008,
EXTENDED: 0x80000000,
} as const;
// Protocol-style aliases make the constants easy to compare with the v3 draft.
export const SSH_FXF_READ = SFTP_OPEN_FLAGS.READ;
export const SSH_FXF_WRITE = SFTP_OPEN_FLAGS.WRITE;
export const SSH_FXF_APPEND = SFTP_OPEN_FLAGS.APPEND;
export const SSH_FXF_CREAT = SFTP_OPEN_FLAGS.CREATE;
export const SSH_FXF_TRUNC = SFTP_OPEN_FLAGS.TRUNCATE;
export const SSH_FXF_EXCL = SFTP_OPEN_FLAGS.EXCLUSIVE;
export const SSH_FILEXFER_ATTR_SIZE = SFTP_ATTR_FLAGS.SIZE;
export const SSH_FILEXFER_ATTR_UIDGID = SFTP_ATTR_FLAGS.UID_GID;
export const SSH_FILEXFER_ATTR_PERMISSIONS = SFTP_ATTR_FLAGS.PERMISSIONS;
export const SSH_FILEXFER_ATTR_ACMODTIME = SFTP_ATTR_FLAGS.ACCESS_MODIFY_TIME;
export const SSH_FILEXFER_ATTR_EXTENDED = SFTP_ATTR_FLAGS.EXTENDED;
export type SFTPOpenFlags = number;
export type SFTPHandle = Uint8Array;
export type SFTPUint64Input = number | bigint | string;
export type SFTPSize = number | string;
export interface SFTPExtendedAttribute {
readonly type: string;
readonly data: Uint8Array;
}
export interface SFTPExtendedAttributeInput {
readonly type: string;
readonly data: string | Uint8Array;
}
export interface SFTPAttrs {
readonly flags: number;
readonly size?: SFTPSize;
readonly uid?: number;
readonly gid?: number;
readonly permissions?: number;
readonly atime?: number;
readonly mtime?: number;
readonly extended?: readonly SFTPExtendedAttribute[];
}
export interface SFTPAttrsInput {
readonly size?: SFTPUint64Input;
readonly uid?: number;
readonly gid?: number;
readonly permissions?: number;
readonly atime?: number;
readonly mtime?: number;
readonly extended?: readonly SFTPExtendedAttributeInput[];
}
export interface SFTPDirEntry {
readonly filename: string;
readonly longname: string;
readonly attrs: SFTPAttrs;
}
export interface SFTPVersionInfo {
readonly version: number;
readonly extensions: ReadonlyMap<string, string>;
}
export interface SFTPClientOptions {
/** Maximum packet length, excluding the four-byte packet length field. */
readonly maxPacketLength?: number;
/** Maximum memory reserved for a partially received framed packet. */
readonly maxBufferedBytes?: number;
readonly maxNameEntries?: number;
readonly maxExtendedAttributes?: number;
readonly maxVersionExtensions?: number;
readonly requestTimeoutMs?: number;
/** Primarily useful for deterministic request-ID rollover tests. */
readonly initialRequestId?: number;
}
export type SFTPFileKind =
| 'file'
| 'directory'
| 'symlink'
| 'socket'
| 'block-device'
| 'character-device'
| 'fifo'
| 'unknown';
const MODE_TYPE_MASK = 0o170000;
export function getSFTPFileKind(value: SFTPAttrs | number | undefined): SFTPFileKind {
const permissions = typeof value === 'number' ? value : value?.permissions;
if (permissions === undefined) return 'unknown';
switch (permissions & MODE_TYPE_MASK) {
case 0o100000: return 'file';
case 0o040000: return 'directory';
case 0o120000: return 'symlink';
case 0o140000: return 'socket';
case 0o060000: return 'block-device';
case 0o020000: return 'character-device';
case 0o010000: return 'fifo';
default: return 'unknown';
}
}
export function formatSFTPPermissions(value: SFTPAttrs | number | undefined): string {
const permissions = typeof value === 'number' ? value : value?.permissions;
if (permissions === undefined) return '??????????';
const prefix: Record<SFTPFileKind, string> = {
file: '-',
directory: 'd',
symlink: 'l',
socket: 's',
'block-device': 'b',
'character-device': 'c',
fifo: 'p',
unknown: '?',
};
const bits = [
permissions & 0o400 ? 'r' : '-',
permissions & 0o200 ? 'w' : '-',
permissions & 0o100 ? 'x' : '-',
permissions & 0o040 ? 'r' : '-',
permissions & 0o020 ? 'w' : '-',
permissions & 0o010 ? 'x' : '-',
permissions & 0o004 ? 'r' : '-',
permissions & 0o002 ? 'w' : '-',
permissions & 0o001 ? 'x' : '-',
];
if (permissions & 0o4000) bits[2] = permissions & 0o100 ? 's' : 'S';
if (permissions & 0o2000) bits[5] = permissions & 0o010 ? 's' : 'S';
if (permissions & 0o1000) bits[8] = permissions & 0o001 ? 't' : 'T';
return prefix[getSFTPFileKind(permissions)] + bits.join('');
}
export const getFileKind = getSFTPFileKind;
export const formatPermissions = formatSFTPPermissions;
+715
View File
@@ -0,0 +1,715 @@
import {
SFTP_ATTR_FLAGS,
SFTP_PACKET_TYPES,
SFTP_STATUS,
SFTP_VERSION,
type SFTPAttrs,
type SFTPAttrsInput,
type SFTPClientOptions,
type SFTPDirEntry,
type SFTPExtendedAttribute,
type SFTPHandle,
type SFTPOpenFlags,
type SFTPSize,
type SFTPUint64Input,
type SFTPVersionInfo,
} from './sftp-types.ts';
export * from './sftp-types.ts';
const UINT32_MAX = 0xffffffff;
const UINT64_MAX = (1n << 64n) - 1n;
const DEFAULT_MAX_PACKET_LENGTH = 4 * 1024 * 1024;
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_NAME_ENTRIES = 16_384;
const DEFAULT_MAX_EXTENDED_ATTRIBUTES = 1_024;
const DEFAULT_MAX_VERSION_EXTENSIONS = 256;
const KNOWN_ATTR_FLAGS = (
SFTP_ATTR_FLAGS.SIZE |
SFTP_ATTR_FLAGS.UID_GID |
SFTP_ATTR_FLAGS.PERMISSIONS |
SFTP_ATTR_FLAGS.ACCESS_MODIFY_TIME |
SFTP_ATTR_FLAGS.EXTENDED
) >>> 0;
const encoder = new TextEncoder();
const decoder = new TextDecoder('utf-8', { fatal: true });
export class SFTPProtocolError extends Error {
constructor(message: string) {
super(message);
this.name = 'SFTPProtocolError';
}
}
export class SFTPStatusError extends Error {
readonly code: number;
readonly requestId: number;
readonly description: string;
readonly language: string;
constructor(code: number, requestId: number, description: string, language: string) {
super(`SFTP request ${requestId} failed with status ${code}${description ? `: ${description}` : ''}`);
this.name = 'SFTPStatusError';
this.code = code;
this.requestId = requestId;
this.description = description;
this.language = language;
}
}
export class SFTPTimeoutError extends Error {
readonly requestId: number;
constructor(requestId: number) {
super(`SFTP request ${requestId} timed out`);
this.name = 'SFTPTimeoutError';
this.requestId = requestId;
}
}
class Reader {
private offset = 0;
private readonly data: Uint8Array;
constructor(data: Uint8Array) {
this.data = data;
}
get remaining(): number {
return this.data.length - this.offset;
}
readByte(label: string): number {
this.require(1, label);
return this.data[this.offset++];
}
readUint32(label: string): number {
this.require(4, label);
const value = new DataView(this.data.buffer, this.data.byteOffset + this.offset, 4).getUint32(0, false);
this.offset += 4;
return value;
}
readUint64(label: string): bigint {
const high = this.readUint32(label);
const low = this.readUint32(label);
return (BigInt(high) << 32n) | BigInt(low);
}
readBytes(length: number, label: string): Uint8Array {
this.require(length, label);
const value = this.data.subarray(this.offset, this.offset + length);
this.offset += length;
return value;
}
readStringBytes(label: string): Uint8Array {
return this.readBytes(this.readUint32(`${label} length`), label);
}
readText(label: string): string {
const bytes = this.readStringBytes(label);
try {
return decoder.decode(bytes);
} catch {
throw new SFTPProtocolError(`Malformed SFTP ${label}: invalid UTF-8`);
}
}
assertEnd(label = 'packet'): void {
if (this.remaining !== 0) {
throw new SFTPProtocolError(`Malformed SFTP ${label}: ${this.remaining} trailing byte(s)`);
}
}
private require(length: number, label: string): void {
if (!Number.isSafeInteger(length) || length < 0 || length > this.remaining) {
throw new SFTPProtocolError(`Malformed SFTP packet: truncated or invalid ${label}`);
}
}
}
interface PendingRequest<T> {
readonly id: number;
readonly timer: ReturnType<typeof setTimeout>;
readonly parse: (type: number, reader: Reader) => T;
readonly resolve: (value: T | PromiseLike<T>) => void;
readonly reject: (reason: unknown) => void;
}
type SendData = (packet: Uint8Array) => void | Promise<void>;
type ClientState = 'new' | 'initializing' | 'ready' | 'disposed';
function uint32(value: number, label: string): number {
if (!Number.isInteger(value) || value < 0 || value > UINT32_MAX) {
throw new RangeError(`${label} must be an unsigned 32-bit integer`);
}
return value;
}
function positiveInteger(value: number, label: string): number {
if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${label} must be a positive integer`);
return value;
}
function toUint64(value: SFTPUint64Input, label: string): bigint {
let result: bigint;
if (typeof value === 'bigint') {
result = value;
} else if (typeof value === 'number') {
if (!Number.isSafeInteger(value)) throw new RangeError(`${label} number must be a safe integer`);
result = BigInt(value);
} else {
if (!/^[0-9]+$/.test(value)) throw new RangeError(`${label} must be an unsigned decimal integer`);
result = BigInt(value);
}
if (result < 0n || result > UINT64_MAX) throw new RangeError(`${label} must fit in an unsigned 64-bit integer`);
return result;
}
function displayUint64(value: bigint): SFTPSize {
return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString(10);
}
function encodeUint32(value: number, label: string): Uint8Array {
const result = new Uint8Array(4);
new DataView(result.buffer).setUint32(0, uint32(value, label), false);
return result;
}
function encodeUint64(value: SFTPUint64Input, label: string): Uint8Array {
const integer = toUint64(value, label);
const result = new Uint8Array(8);
const view = new DataView(result.buffer);
view.setUint32(0, Number(integer >> 32n), false);
view.setUint32(4, Number(integer & 0xffffffffn), false);
return result;
}
function encodeString(value: string | Uint8Array, label: string): Uint8Array {
const bytes = typeof value === 'string' ? encoder.encode(value) : value;
if (bytes.length > UINT32_MAX) throw new RangeError(`${label} is too long`);
const result = new Uint8Array(4 + bytes.length);
new DataView(result.buffer).setUint32(0, bytes.length, false);
result.set(bytes, 4);
return result;
}
function join(parts: readonly Uint8Array[]): Uint8Array {
let length = 0;
for (const part of parts) {
length += part.length;
if (!Number.isSafeInteger(length)) throw new RangeError('SFTP packet is too large');
}
const result = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
result.set(part, offset);
offset += part.length;
}
return result;
}
function encodeAttrs(attrs: SFTPAttrsInput | undefined, maxExtended: number): Uint8Array {
if (!attrs) return encodeUint32(0, 'attribute flags');
let flags = 0;
const values: Uint8Array[] = [];
if (attrs.size !== undefined) {
flags |= SFTP_ATTR_FLAGS.SIZE;
values.push(encodeUint64(attrs.size, 'file size'));
}
if ((attrs.uid === undefined) !== (attrs.gid === undefined)) {
throw new TypeError('SFTP attributes must provide uid and gid together');
}
if (attrs.uid !== undefined && attrs.gid !== undefined) {
flags |= SFTP_ATTR_FLAGS.UID_GID;
values.push(encodeUint32(attrs.uid, 'uid'), encodeUint32(attrs.gid, 'gid'));
}
if (attrs.permissions !== undefined) {
flags |= SFTP_ATTR_FLAGS.PERMISSIONS;
values.push(encodeUint32(attrs.permissions, 'permissions'));
}
if ((attrs.atime === undefined) !== (attrs.mtime === undefined)) {
throw new TypeError('SFTP attributes must provide atime and mtime together');
}
if (attrs.atime !== undefined && attrs.mtime !== undefined) {
flags |= SFTP_ATTR_FLAGS.ACCESS_MODIFY_TIME;
values.push(encodeUint32(attrs.atime, 'access time'), encodeUint32(attrs.mtime, 'modification time'));
}
if (attrs.extended !== undefined) {
if (attrs.extended.length > maxExtended) throw new RangeError('Too many SFTP extended attributes');
flags = (flags | SFTP_ATTR_FLAGS.EXTENDED) >>> 0;
values.push(encodeUint32(attrs.extended.length, 'extended attribute count'));
for (const extension of attrs.extended) {
values.push(encodeString(extension.type, 'extended attribute type'));
values.push(encodeString(extension.data, 'extended attribute data'));
}
}
return join([encodeUint32(flags >>> 0, 'attribute flags'), ...values]);
}
function parseAttrs(reader: Reader, maxExtended: number): SFTPAttrs {
const flags = reader.readUint32('attribute flags');
if ((flags & ~KNOWN_ATTR_FLAGS) !== 0) {
throw new SFTPProtocolError(`Malformed SFTP attributes: unsupported flags 0x${flags.toString(16)}`);
}
let size: SFTPSize | undefined;
let uid: number | undefined;
let gid: number | undefined;
let permissions: number | undefined;
let atime: number | undefined;
let mtime: number | undefined;
let extended: SFTPExtendedAttribute[] | undefined;
if (flags & SFTP_ATTR_FLAGS.SIZE) size = displayUint64(reader.readUint64('file size'));
if (flags & SFTP_ATTR_FLAGS.UID_GID) {
uid = reader.readUint32('uid');
gid = reader.readUint32('gid');
}
if (flags & SFTP_ATTR_FLAGS.PERMISSIONS) permissions = reader.readUint32('permissions');
if (flags & SFTP_ATTR_FLAGS.ACCESS_MODIFY_TIME) {
atime = reader.readUint32('access time');
mtime = reader.readUint32('modification time');
}
if (flags & SFTP_ATTR_FLAGS.EXTENDED) {
const count = reader.readUint32('extended attribute count');
if (count > maxExtended || count > Math.floor(reader.remaining / 8)) {
throw new SFTPProtocolError('Malformed SFTP attributes: invalid extended attribute count');
}
extended = [];
for (let index = 0; index < count; index++) {
extended.push({
type: reader.readText('extended attribute type'),
data: reader.readStringBytes('extended attribute data').slice(),
});
}
}
return { flags, size, uid, gid, permissions, atime, mtime, extended };
}
function parseStatus(reader: Reader, requestId: number): { code: number; description: string; language: string } {
const code = reader.readUint32('status code');
const description = reader.readText('status description');
const language = reader.readText('status language');
reader.assertEnd('STATUS packet');
return { code, description, language };
}
export class SFTPClient {
private readonly sendData: SendData;
private readonly maxPacketLength: number;
private readonly maxBufferedBytes: number;
private readonly maxNameEntries: number;
private readonly maxExtendedAttributes: number;
private readonly maxVersionExtensions: number;
private readonly requestTimeoutMs: number;
private readonly pending = new Map<number, PendingRequest<unknown>>();
private state: ClientState = 'new';
private nextRequestId: number;
private header = new Uint8Array(4);
private headerLength = 0;
private packetBuffer: Uint8Array | null = null;
private packetOffset = 0;
private initializationPromise: Promise<SFTPVersionInfo> | null = null;
private initializationResolve: ((info: SFTPVersionInfo) => void) | null = null;
private initializationReject: ((reason: unknown) => void) | null = null;
private initializationTimer: ReturnType<typeof setTimeout> | null = null;
constructor(sendData: SendData, options: SFTPClientOptions = {}) {
this.sendData = sendData;
this.maxPacketLength = positiveInteger(options.maxPacketLength ?? DEFAULT_MAX_PACKET_LENGTH, 'maxPacketLength');
this.maxBufferedBytes = positiveInteger(
options.maxBufferedBytes ?? this.maxPacketLength + 4,
'maxBufferedBytes',
);
if (this.maxBufferedBytes < 5) throw new RangeError('maxBufferedBytes must hold an SFTP frame');
this.maxNameEntries = positiveInteger(options.maxNameEntries ?? DEFAULT_MAX_NAME_ENTRIES, 'maxNameEntries');
this.maxExtendedAttributes = positiveInteger(
options.maxExtendedAttributes ?? DEFAULT_MAX_EXTENDED_ATTRIBUTES,
'maxExtendedAttributes',
);
this.maxVersionExtensions = positiveInteger(
options.maxVersionExtensions ?? DEFAULT_MAX_VERSION_EXTENSIONS,
'maxVersionExtensions',
);
this.requestTimeoutMs = positiveInteger(
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
'requestTimeoutMs',
);
this.nextRequestId = uint32(options.initialRequestId ?? 1, 'initialRequestId');
if (this.nextRequestId === 0) throw new RangeError('initialRequestId must not be zero');
}
initialize(): Promise<SFTPVersionInfo> {
if (this.state === 'disposed') throw new Error('SFTP client is disposed');
if (this.initializationPromise) return this.initializationPromise;
this.assertState('new');
this.state = 'initializing';
this.initializationPromise = new Promise<SFTPVersionInfo>((resolve, reject) => {
this.initializationResolve = resolve;
this.initializationReject = reject;
this.initializationTimer = setTimeout(() => {
const error = new SFTPTimeoutError(0);
this.failInitialization(error);
this.dispose(error);
}, this.requestTimeoutMs);
});
try {
const sent = this.sendFramed(join([
new Uint8Array([SFTP_PACKET_TYPES.INIT]),
encodeUint32(SFTP_VERSION, 'SFTP version'),
]));
if (sent) sent.catch((error: unknown) => {
if (this.state === 'initializing') {
this.failInitialization(error);
this.dispose(error instanceof Error ? error : new Error(String(error)));
}
});
} catch (error) {
this.failInitialization(error);
this.dispose(error instanceof Error ? error : new Error(String(error)));
}
return this.initializationPromise;
}
feed(chunk: Uint8Array): void {
if (this.state === 'disposed') throw new Error('SFTP client is disposed');
if (!(chunk instanceof Uint8Array)) throw new TypeError('SFTP data must be a Uint8Array');
try {
let offset = 0;
while (offset < chunk.length) {
if (this.packetBuffer) {
const count = Math.min(this.packetBuffer.length - this.packetOffset, chunk.length - offset);
this.packetBuffer.set(chunk.subarray(offset, offset + count), this.packetOffset);
this.packetOffset += count;
offset += count;
if (this.packetOffset === this.packetBuffer.length) {
const packet = this.packetBuffer;
this.packetBuffer = null;
this.packetOffset = 0;
this.dispatch(packet);
}
continue;
}
if (this.headerLength === 0 && chunk.length - offset >= 4) {
const length = new DataView(chunk.buffer, chunk.byteOffset + offset, 4).getUint32(0, false);
this.validatePacketLength(length);
offset += 4;
if (chunk.length - offset >= length) {
this.dispatch(chunk.subarray(offset, offset + length));
offset += length;
} else {
this.packetBuffer = new Uint8Array(length);
this.packetOffset = chunk.length - offset;
this.packetBuffer.set(chunk.subarray(offset), 0);
offset = chunk.length;
}
continue;
}
const headerBytes = Math.min(4 - this.headerLength, chunk.length - offset);
this.header.set(chunk.subarray(offset, offset + headerBytes), this.headerLength);
this.headerLength += headerBytes;
offset += headerBytes;
if (this.headerLength === 4) {
const length = new DataView(this.header.buffer).getUint32(0, false);
this.headerLength = 0;
this.validatePacketLength(length);
this.packetBuffer = new Uint8Array(length);
this.packetOffset = 0;
}
}
} catch (error) {
const protocolError = error instanceof Error ? error : new SFTPProtocolError(String(error));
this.dispose(protocolError);
throw protocolError;
}
}
dispose(reason: Error = new Error('SFTP client was disposed')): void {
if (this.state === 'disposed') return;
this.state = 'disposed';
this.headerLength = 0;
this.packetBuffer = null;
this.packetOffset = 0;
this.failInitialization(reason);
for (const request of this.pending.values()) {
clearTimeout(request.timer);
request.reject(reason);
}
this.pending.clear();
}
realpath(path: string): Promise<string> {
return this.request(SFTP_PACKET_TYPES.REALPATH, [encodeString(path, 'path')], (type, reader, id) => {
this.throwStatusOrUnexpected(type, reader, id, SFTP_PACKET_TYPES.NAME);
const entries = this.parseName(reader);
if (entries.length !== 1) throw new SFTPProtocolError('Malformed SFTP REALPATH response: expected one name');
return entries[0].filename;
});
}
openDir(path: string): Promise<SFTPHandle> {
return this.request(SFTP_PACKET_TYPES.OPENDIR, [encodeString(path, 'path')], (type, reader, id) => {
this.throwStatusOrUnexpected(type, reader, id, SFTP_PACKET_TYPES.HANDLE);
const handle = reader.readStringBytes('handle');
if (handle.length === 0) throw new SFTPProtocolError('Malformed SFTP HANDLE response: empty handle');
return handle.slice();
});
}
readDir(handle: SFTPHandle): Promise<SFTPDirEntry[] | null> {
return this.request(SFTP_PACKET_TYPES.READDIR, [encodeString(handle, 'handle')], (type, reader, id) => {
if (type === SFTP_PACKET_TYPES.STATUS) return this.parseEofStatus(reader, id);
if (type !== SFTP_PACKET_TYPES.NAME) this.unexpected(type, SFTP_PACKET_TYPES.NAME);
return this.parseName(reader);
});
}
closeHandle(handle: SFTPHandle): Promise<void> {
return this.statusRequest(SFTP_PACKET_TYPES.CLOSE, [encodeString(handle, 'handle')]);
}
stat(path: string): Promise<SFTPAttrs> {
return this.request(SFTP_PACKET_TYPES.STAT, [encodeString(path, 'path')], (type, reader, id) => {
this.throwStatusOrUnexpected(type, reader, id, SFTP_PACKET_TYPES.ATTRS);
return parseAttrs(reader, this.maxExtendedAttributes);
});
}
openFile(path: string, flags: SFTPOpenFlags, attrs?: SFTPAttrsInput): Promise<SFTPHandle> {
return this.request(SFTP_PACKET_TYPES.OPEN, [
encodeString(path, 'path'),
encodeUint32(flags, 'open flags'),
encodeAttrs(attrs, this.maxExtendedAttributes),
], (type, reader, id) => {
this.throwStatusOrUnexpected(type, reader, id, SFTP_PACKET_TYPES.HANDLE);
const handle = reader.readStringBytes('handle');
if (handle.length === 0) throw new SFTPProtocolError('Malformed SFTP HANDLE response: empty handle');
return handle.slice();
});
}
readFile(handle: SFTPHandle, offset: SFTPUint64Input, length: number): Promise<Uint8Array | null> {
return this.request(SFTP_PACKET_TYPES.READ, [
encodeString(handle, 'handle'),
encodeUint64(offset, 'file offset'),
encodeUint32(length, 'read length'),
], (type, reader, id) => {
if (type === SFTP_PACKET_TYPES.STATUS) return this.parseEofStatus(reader, id);
if (type !== SFTP_PACKET_TYPES.DATA) this.unexpected(type, SFTP_PACKET_TYPES.DATA);
const data = reader.readStringBytes('file data');
if (data.length > length) throw new SFTPProtocolError('Malformed SFTP DATA response: exceeds requested length');
return data.slice();
});
}
writeFile(handle: SFTPHandle, offset: SFTPUint64Input, data: Uint8Array): Promise<void> {
return this.statusRequest(SFTP_PACKET_TYPES.WRITE, [
encodeString(handle, 'handle'),
encodeUint64(offset, 'file offset'),
encodeString(data, 'file data'),
]);
}
removeFile(path: string): Promise<void> {
return this.statusRequest(SFTP_PACKET_TYPES.REMOVE, [encodeString(path, 'path')]);
}
mkdir(path: string, attrs?: SFTPAttrsInput): Promise<void> {
return this.statusRequest(SFTP_PACKET_TYPES.MKDIR, [
encodeString(path, 'path'),
encodeAttrs(attrs, this.maxExtendedAttributes),
]);
}
rmdir(path: string): Promise<void> {
return this.statusRequest(SFTP_PACKET_TYPES.RMDIR, [encodeString(path, 'path')]);
}
rename(oldPath: string, newPath: string): Promise<void> {
return this.statusRequest(SFTP_PACKET_TYPES.RENAME, [
encodeString(oldPath, 'old path'),
encodeString(newPath, 'new path'),
]);
}
private request<T>(
type: number,
fields: readonly Uint8Array[],
parse: (type: number, reader: Reader, requestId: number) => T,
): Promise<T> {
this.assertState('ready');
const id = this.allocateRequestId();
const body = join([new Uint8Array([type]), encodeUint32(id, 'request ID'), ...fields]);
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
if (this.pending.delete(id)) reject(new SFTPTimeoutError(id));
}, this.requestTimeoutMs);
const pending: PendingRequest<T> = { id, timer, parse: (responseType, reader) => parse(responseType, reader, id), resolve, reject };
this.pending.set(id, pending as PendingRequest<unknown>);
try {
const sent = this.sendFramed(body);
if (sent) sent.catch((error: unknown) => this.failPending(id, error));
} catch (error) {
this.failPending(id, error);
}
});
}
private statusRequest(type: number, fields: readonly Uint8Array[]): Promise<void> {
return this.request(type, fields, (responseType, reader, id) => {
if (responseType !== SFTP_PACKET_TYPES.STATUS) this.unexpected(responseType, SFTP_PACKET_TYPES.STATUS);
const status = parseStatus(reader, id);
if (status.code !== SFTP_STATUS.OK) {
throw new SFTPStatusError(status.code, id, status.description, status.language);
}
});
}
private dispatch(packet: Uint8Array): void {
const reader = new Reader(packet);
const type = reader.readByte('packet type');
if (type === SFTP_PACKET_TYPES.VERSION) {
this.handleVersion(reader);
return;
}
if (this.state !== 'ready') throw new SFTPProtocolError('Received an SFTP response before version negotiation');
const id = reader.readUint32('request ID');
if (id === 0) throw new SFTPProtocolError('Received an SFTP response with request ID zero');
const request = this.pending.get(id);
if (!request) throw new SFTPProtocolError(`Received an SFTP response with unknown request ID ${id}`);
this.pending.delete(id);
clearTimeout(request.timer);
try {
const value = request.parse(type, reader);
reader.assertEnd();
request.resolve(value);
} catch (error) {
request.reject(error);
if (!(error instanceof SFTPStatusError)) throw error;
}
}
private handleVersion(reader: Reader): void {
if (this.state !== 'initializing') throw new SFTPProtocolError('Unexpected or duplicate SFTP VERSION packet');
const version = reader.readUint32('server version');
if (version !== SFTP_VERSION) throw new SFTPProtocolError(`Unsupported SFTP server version ${version}`);
const extensions = new Map<string, string>();
let count = 0;
while (reader.remaining > 0) {
if (++count > this.maxVersionExtensions || reader.remaining < 8) {
throw new SFTPProtocolError('Malformed SFTP VERSION extensions');
}
const name = reader.readText('extension name');
const data = reader.readText('extension data');
if (extensions.has(name)) throw new SFTPProtocolError(`Duplicate SFTP extension ${name}`);
extensions.set(name, data);
}
reader.assertEnd('VERSION packet');
this.state = 'ready';
if (this.initializationTimer) clearTimeout(this.initializationTimer);
this.initializationTimer = null;
const resolve = this.initializationResolve;
this.initializationResolve = null;
this.initializationReject = null;
resolve?.({ version, extensions });
}
private parseName(reader: Reader): SFTPDirEntry[] {
const count = reader.readUint32('name count');
if (count > this.maxNameEntries || count > Math.floor(reader.remaining / 12)) {
throw new SFTPProtocolError('Malformed SFTP NAME response: invalid name count');
}
const result: SFTPDirEntry[] = [];
for (let index = 0; index < count; index++) {
result.push({
filename: reader.readText('filename'),
longname: reader.readText('long filename'),
attrs: parseAttrs(reader, this.maxExtendedAttributes),
});
}
return result;
}
private parseEofStatus(reader: Reader, id: number): null {
const status = parseStatus(reader, id);
if (status.code === SFTP_STATUS.EOF) return null;
throw new SFTPStatusError(status.code, id, status.description, status.language);
}
private throwStatusOrUnexpected(type: number, reader: Reader, id: number, expected: number): void {
if (type === expected) return;
if (type !== SFTP_PACKET_TYPES.STATUS) this.unexpected(type, expected);
const status = parseStatus(reader, id);
throw new SFTPStatusError(status.code, id, status.description, status.language);
}
private unexpected(actual: number, expected: number): never {
throw new SFTPProtocolError(`Unexpected SFTP response type ${actual}; expected ${expected}`);
}
private sendFramed(body: Uint8Array): Promise<void> | null {
if (body.length === 0 || body.length > this.maxPacketLength || body.length > UINT32_MAX) {
throw new RangeError(`SFTP packet length ${body.length} exceeds the configured limit`);
}
const frame = new Uint8Array(4 + body.length);
new DataView(frame.buffer).setUint32(0, body.length, false);
frame.set(body, 4);
const result = this.sendData(frame);
return result && typeof (result as Promise<void>).then === 'function' ? Promise.resolve(result) : null;
}
private validatePacketLength(length: number): void {
const bufferedPacketLimit = this.maxBufferedBytes - 4;
if (length < 1 || length > this.maxPacketLength || length > bufferedPacketLimit) {
throw new SFTPProtocolError(`Invalid or oversized SFTP packet length ${length}`);
}
}
private allocateRequestId(): number {
let candidate = this.nextRequestId;
for (let attempts = 0; attempts <= this.pending.size; attempts++) {
if (candidate !== 0 && !this.pending.has(candidate)) {
this.nextRequestId = (candidate + 1) >>> 0;
if (this.nextRequestId === 0) this.nextRequestId = 1;
return candidate;
}
candidate = (candidate + 1) >>> 0;
if (candidate === 0) candidate = 1;
}
throw new Error('No SFTP request IDs are available');
}
private failPending(id: number, reason: unknown): void {
const request = this.pending.get(id);
if (!request) return;
this.pending.delete(id);
clearTimeout(request.timer);
request.reject(reason);
}
private failInitialization(reason: unknown): void {
if (this.initializationTimer) clearTimeout(this.initializationTimer);
this.initializationTimer = null;
const reject = this.initializationReject;
this.initializationResolve = null;
this.initializationReject = null;
reject?.(reason);
}
private assertState(required: ClientState): void {
if (this.state !== required) {
throw new Error(this.state === 'disposed' ? 'SFTP client is disposed' : `SFTP client is not ${required}`);
}
}
}
+40
View File
@@ -9,6 +9,11 @@ function clientAddress(request: Request): string {
return /^[0-9A-Fa-f:.]{2,64}$/.test(value) ? value.toLowerCase() : 'unknown';
}
function hasValidWebSocketOrigin(request: Request): boolean {
const origin = request.headers.get('Origin');
return origin === null || origin === new URL(request.url).origin;
}
async function sessionTicket(request: Request, env: Env): Promise<Response> {
if (!request.headers.get('Content-Type')?.toLowerCase().startsWith('application/json')) return jsonError('Expected application/json', 415);
const contentLength = Number(request.headers.get('Content-Length') ?? 0);
@@ -36,6 +41,7 @@ async function sessionTicket(request: Request, env: Env): Promise<Response> {
async function sshUpgrade(request: Request, env: Env): Promise<Response> {
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') return jsonError('WebSocket upgrade required', 426);
const url = new URL(request.url);
if (!hasValidWebSocketOrigin(request)) return jsonError('WebSocket origin is not allowed', 403);
const ticket = url.searchParams.get('ticket');
const sessionId = url.searchParams.get('session');
if (!ticket || !sessionId) return jsonError('Missing session ticket', 401);
@@ -44,13 +50,43 @@ async function sshUpgrade(request: Request, env: Env): Promise<Response> {
const headers = new Headers(request.headers);
headers.delete('Cookie');
headers.delete('Authorization');
const sftpAttachToken = crypto.randomUUID();
const sftpAttachUrl = new URL('/api/sftp', 'https://session.invalid');
sftpAttachUrl.searchParams.set('session', id.toString());
sftpAttachUrl.searchParams.set('token', sftpAttachToken);
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}`);
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;
}
async function sftpUpgrade(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 SFTP 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-sftp-attach-url');
headers.set('x-sftp-attach-token', token);
const response = await env.SSH_SESSIONS.get(id).fetch(new Request('https://session.internal/sftp', {
method: 'GET',
headers,
}));
if (response.status === 401) return jsonError('Invalid, expired, or already used SFTP attachment token', 401);
return response;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
@@ -77,6 +113,10 @@ export default {
// 放行(不重新包装);失败时返回 JSON 错误,正常附加 CORS 头。
return corsResponse(await sshUpgrade(request, env));
}
if (url.pathname === '/api/sftp') {
if (request.method !== 'GET') return corsResponse(jsonError('Method not allowed', 405));
return corsResponse(await sftpUpgrade(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));
+1
View File
@@ -3,6 +3,7 @@
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"lib": ["ES2022", "WebWorker"],
"types": ["@cloudflare/workers-types"],
"strict": true,