14 files changed
+3668
-112
No files matched your search
@@ -7,3 +7,4 @@ coverage/
|
||||
.env*
|
||||
!.env.example
|
||||
.workbuddy/
|
||||
tests/
|
||||
@@ -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
@@ -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">>_</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">>_</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">←</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">↑</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">⌂</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">↻</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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"lib": ["ES2022", "WebWorker"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
|
||||
Reference in new issue
Block a user