From 289d662fc244140c23c5114f55a9e7db4ec9895a Mon Sep 17 00:00:00 2001 From: cmliu Date: Tue, 28 Jul 2026 23:04:46 +0800 Subject: [PATCH 1/4] feat: implement SFTP protocol support with types and client functionality - Added sftp-types.ts to define SFTP protocol constants, types, and attributes. - Introduced sftp.ts to implement the SFTP client, handling requests, responses, and error management. - Implemented methods for file operations including open, read, write, remove, and directory management. - Added error handling for SFTP protocol errors and timeouts. - Included utility functions for encoding and decoding SFTP packets. --- README.md | 11 +- frontend/index.html | 171 +++-- frontend/src/file-manager.ts | 1149 +++++++++++++++++++++++++++++++++ frontend/src/main.ts | 95 ++- frontend/src/style.css | 400 +++++++++++- src/backend/durable-object.ts | 174 ++++- src/backend/session.ts | 445 +++++++++++-- src/backend/sftp-handler.ts | 396 ++++++++++++ src/ssh/auth-password.ts | 97 ++- src/ssh/auth.ts | 24 +- src/ssh/channel.ts | 38 ++ src/ssh/sftp-types.ts | 197 ++++++ src/ssh/sftp.ts | 715 ++++++++++++++++++++ src/worker.ts | 40 ++ tests/auth.test.ts | 142 ++++ tests/host-key.test.ts | 30 + tsconfig.worker.json | 1 + 17 files changed, 3977 insertions(+), 148 deletions(-) create mode 100644 frontend/src/file-manager.ts create mode 100644 src/backend/sftp-handler.ts create mode 100644 src/ssh/sftp-types.ts create mode 100644 src/ssh/sftp.ts create mode 100644 tests/auth.test.ts create mode 100644 tests/host-key.test.ts diff --git a/README.md b/README.md index d78ceff..e8483fa 100644 --- a/README.md +++ b/README.md @@ -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 和限流策略提供。 diff --git a/frontend/index.html b/frontend/index.html index c603bcd..0ee690b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -240,52 +240,143 @@
-
-
- -
- 无活动会话 - 选择目标并连接 +
+
+
+ +
+ 无活动会话 + 选择目标并连接 +
+
+
+
延迟--
+
时长00:00
+
主机密钥--
+
+
+ +
-
-
延迟--
-
时长00:00
-
主机密钥--
-
-
- - +
+
+
+ +

终端已就绪

+

从连接面板建立 SSH 连接,开始加密的边缘会话。

+ +
-
-
-
- -

终端已就绪

-

从连接面板建立 SSH 连接,开始加密的边缘会话。

- + +
+
+ Worker 运行时待命 +
+ + +
+ +
+
+ +
+ + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + +
名称大小类型修改时间权限用户组
+ + + +
+
+ 连接 SSH 后即可管理文件 + +
+
+
-
- Worker 运行时待命 - -
-
diff --git a/frontend/src/file-manager.ts b/frontend/src/file-manager.ts new file mode 100644 index 0000000..d04f437 --- /dev/null +++ b/frontend/src/file-manager.ts @@ -0,0 +1,1149 @@ +export const MAX_SFTP_FILE_SIZE = 64 * 1024 * 1024; +export const SFTP_UPLOAD_CHUNK_SIZE = 64 * 1024; + +export interface FileManagerElements { + panel: HTMLElement; + path: HTMLInputElement; + back: HTMLButtonElement; + up: HTMLButtonElement; + home: HTMLButtonElement; + refresh: HTMLButtonElement; + upload: HTMLButtonElement; + download: HTMLButtonElement; + mkdir: HTMLButtonElement; + rename: HTMLButtonElement; + delete: HTMLButtonElement; + uploadInput: HTMLInputElement; + tableBody: HTMLTableSectionElement; + loading: HTMLElement; + empty: HTMLElement; + error: HTMLElement; + status: HTMLElement; + progress: HTMLElement; + progressLabel: HTMLElement; + progressBar: HTMLProgressElement; + progressCancel: HTMLButtonElement; +} + +export interface FileManagerOptions { + elements: FileManagerElements; + getLanguage?: () => string; + confirm?: (message: string) => boolean | Promise; + prompt?: (message: string, defaultValue?: string) => string | null | Promise; + onError?: (message: string) => void; + onStatus?: (message: string) => void; +} + +export interface SFTPEntry { + name: string; + type: 'file' | 'directory' | 'symlink' | 'other'; + size: number | string; + mtime: string | number; + permissions: string | number; + uid?: string | number; + gid?: string | number; + owner?: string; + group?: string; +} + +interface LocalizedText { + zh: string; + en: string; +} + +interface PendingRequest { + generation: number; + operation: string; + path: string; + historyMode?: 'push' | 'none' | 'index'; + historyIndex?: number; +} + +interface UploadState { + generation: number; + requestId: string; + file: File; + path: string; + sent: number; + acknowledged: number; + awaitingAck: boolean; + endSent: boolean; + readyForData: boolean; + cancelling: boolean; + cancelSent: boolean; +} + +interface DownloadState { + generation: number; + requestId: string; + path: string; + name: string; + expectedSize: number | null; + received: number; + chunks: ArrayBuffer[]; + started: boolean; + cancelling: boolean; + cancelSent: boolean; +} + +type SocketData = string | ArrayBuffer | ArrayBufferView | Blob; +type JsonRecord = Record; + +const DISCONNECTED: LocalizedText = { + zh: '连接 SSH 后即可管理文件', + en: 'Connect to SSH to manage files', +}; + +function requiredElement(root: ParentNode, id: string): T { + const node = root.querySelector(`#${id}`); + if (!node) throw new Error(`Missing file manager element #${id}`); + return node; +} + +export function collectFileManagerElements(root: ParentNode = document): FileManagerElements { + return { + panel: requiredElement(root, 'file-manager-panel'), + path: requiredElement(root, 'file-manager-path'), + back: requiredElement(root, 'file-back'), + up: requiredElement(root, 'file-up'), + home: requiredElement(root, 'file-home'), + refresh: requiredElement(root, 'file-refresh'), + upload: requiredElement(root, 'file-upload'), + download: requiredElement(root, 'file-download'), + mkdir: requiredElement(root, 'file-mkdir'), + rename: requiredElement(root, 'file-rename'), + delete: requiredElement(root, 'file-delete'), + uploadInput: requiredElement(root, 'file-upload-input'), + tableBody: requiredElement(root, 'file-table-body'), + loading: requiredElement(root, 'file-manager-loading'), + empty: requiredElement(root, 'file-manager-empty'), + error: requiredElement(root, 'file-manager-error'), + status: requiredElement(root, 'file-manager-status'), + progress: requiredElement(root, 'file-manager-progress'), + progressLabel: requiredElement(root, 'file-manager-progress-label'), + progressBar: requiredElement(root, 'file-manager-progress-bar'), + progressCancel: requiredElement(root, 'file-manager-progress-cancel'), + }; +} + +function normalizePath(input: string): string { + if (typeof input !== 'string' || input.length === 0 || new TextEncoder().encode(input).length > 4096) throw new Error('Invalid path'); + if (!input.startsWith('/') || /[\0-\x1f\x7f]/.test(input)) throw new Error('Invalid path'); + return input; +} + +function validateName(input: string): string { + const name = input; + if (!name || name === '.' || name === '..' || name.length > 255 || name.includes('/') || /[\0-\x1f\x7f]/.test(name)) { + throw new Error('Invalid file name'); + } + return name; +} + +function joinPath(parent: string, name: string): string { + return normalizePath(`${parent.endsWith('/') ? parent : `${parent}/`}${validateName(name)}`); +} + +function parentPath(path: string): string { + let normalized = normalizePath(path); + while (normalized.length > 1 && normalized.endsWith('/')) normalized = normalized.slice(0, -1); + if (normalized === '/') return '/'; + return normalized.slice(0, normalized.lastIndexOf('/')) || '/'; +} + +function isRecord(value: unknown): value is JsonRecord { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function numberValue(value: unknown): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function fileSizeValue(value: unknown): number | string | null { + const numeric = numberValue(value); + if (numeric !== null) return numeric; + if (typeof value !== 'string' || !/^\d+$/.test(value)) return null; + return value.replace(/^0+(?=\d)/, ''); +} + +function sizeExceedsLimit(value: number | string): boolean { + if (typeof value === 'number') return value > MAX_SFTP_FILE_SIZE; + const limit = String(MAX_SFTP_FILE_SIZE); + return value.length > limit.length || (value.length === limit.length && value > limit); +} + +function transferSize(value: unknown): number | null { + const size = fileSizeValue(value); + if (size === null || sizeExceedsLimit(size)) return null; + return typeof size === 'number' ? size : Number(size); +} + +export class FileManager { + private readonly elements: FileManagerElements; + private readonly getLanguage: () => string; + private readonly confirmAction: NonNullable; + private readonly promptAction: NonNullable; + private readonly onError?: FileManagerOptions['onError']; + private readonly onStatus?: FileManagerOptions['onStatus']; + private readonly bindings = new AbortController(); + + private socket: WebSocket | null = null; + private generation = 0; + private requestSequence = 0; + private ready = false; + private cwd = '/'; + private homePath = '/'; + private history = ['/']; + private historyIndex = 0; + private entries: SFTPEntry[] = []; + private selectedIndex = -1; + private pending = new Map(); + private activeListRequest: string | null = null; + private uploadState: UploadState | null = null; + private downloadState: DownloadState | null = null; + private uploadConfirmationPending = false; + private messageQueue: Promise = Promise.resolve(); + private statusCopy: LocalizedText = DISCONNECTED; + + constructor(options: FileManagerOptions) { + this.elements = options.elements; + this.getLanguage = options.getLanguage ?? (() => document.documentElement.lang || 'zh-CN'); + this.confirmAction = options.confirm ?? ((message) => window.confirm(message)); + this.promptAction = options.prompt ?? ((message, value) => window.prompt(message, value)); + this.onError = options.onError; + this.onStatus = options.onStatus; + this.bindControls(); + this.renderDisconnected(); + } + + attach(url: string): void { + this.reset(); + let target: URL; + try { + target = new URL(url, window.location.href); + if (target.protocol === 'http:') target.protocol = 'ws:'; + if (target.protocol === 'https:') target.protocol = 'wss:'; + const expectedProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + if (target.host !== window.location.host || target.protocol !== expectedProtocol || target.username || target.password) { + throw new Error('SFTP WebSocket must use the current origin'); + } + target.hash = ''; + } catch (error) { + this.showError(this.localize({ zh: '文件传输地址无效。', en: 'The file transfer address is invalid.' })); + throw error; + } + + const generation = this.generation; + const socket = new WebSocket(target); + socket.binaryType = 'arraybuffer'; + this.socket = socket; + this.setStatus({ zh: '正在连接文件服务…', en: 'Connecting to file service…' }); + + socket.addEventListener('open', () => { + if (!this.isCurrent(socket, generation)) return; + this.init(); + }); + socket.addEventListener('message', (event: MessageEvent) => { + this.messageQueue = this.messageQueue + .then(() => this.handleSocketData(event.data, generation)) + .catch((error: unknown) => this.showError(error instanceof Error ? error.message : String(error))); + }); + socket.addEventListener('error', () => { + if (this.isCurrent(socket, generation)) { + this.showError(this.localize({ zh: '文件服务连接失败。', en: 'The file service connection failed.' })); + } + }); + socket.addEventListener('close', () => { + if (!this.isCurrent(socket, generation)) return; + this.socket = null; + this.ready = false; + this.abortTransfers(false); + this.pending.clear(); + this.activeListRequest = null; + this.renderDisconnected(); + }); + } + + init(): void { + if (!this.isSocketOpen()) return; + this.send({ type: 'sftp_init' }); + this.setStatus({ zh: '正在初始化 SFTP…', en: 'Initializing SFTP…' }); + } + + reset(): void { + this.generation++; + const socket = this.socket; + this.socket = null; + this.ready = false; + this.uploadConfirmationPending = false; + this.pending.clear(); + this.activeListRequest = null; + this.abortTransfers(false); + if (socket) { + if (socket.readyState === WebSocket.OPEN) { + try { socket.send(JSON.stringify({ type: 'sftp_close' })); } catch { /* Socket is already unusable. */ } + } + if (socket.readyState < WebSocket.CLOSING) socket.close(1000, 'SFTP client reset'); + } + this.cwd = '/'; + this.homePath = '/'; + this.history = ['/']; + this.historyIndex = 0; + this.entries = []; + this.selectedIndex = -1; + this.renderDisconnected(); + } + + destroy(): void { + this.reset(); + this.bindings.abort(); + } + + list(path = this.cwd): string | null { + return this.requestList(path, 'push'); + } + + navigate(path: string): void { + try { + this.requestList(normalizePath(path), 'push'); + } catch { + this.showError(this.localize({ zh: '请输入有效的绝对路径。', en: 'Enter a valid absolute path.' })); + } + } + + back(): void { + if (!this.ready || this.historyIndex <= 0) return; + this.requestList(this.history[this.historyIndex - 1], 'index', this.historyIndex - 1); + } + + up(): void { + if (this.ready) this.requestList(parentPath(this.cwd), 'push'); + } + + home(): void { + if (this.ready) this.requestList(this.homePath, 'push'); + } + + refresh(): void { + if (this.ready) this.requestList(this.cwd, 'none'); + } + + setLanguage(): void { + this.setStatus(this.statusCopy); + this.renderEntries(); + this.updateProgressLanguage(); + } + + async upload(file: File, destination?: string): Promise { + if (!this.ready || this.hasTransfer() || this.uploadConfirmationPending) return; + if (file.size > MAX_SFTP_FILE_SIZE) { + this.showError(this.localize({ zh: '文件不能超过 64 MiB。', en: 'Files cannot exceed 64 MiB.' })); + return; + } + let path: string; + try { path = normalizePath(destination ?? joinPath(this.cwd, file.name)); } catch { + this.showError(this.localize({ zh: '上传文件名无效。', en: 'The upload file name is invalid.' })); + return; + } + const existing = this.entries.find((entry) => joinPath(this.cwd, entry.name) === path); + const generation = this.generation; + let overwrite = false; + if (existing) { + this.uploadConfirmationPending = true; + this.updateControls(); + try { + overwrite = await this.confirmAction(this.localize({ + zh: `“${existing.name}”已存在。是否覆盖?此操作无法撤销。`, + en: `“${existing.name}” already exists. Overwrite it? This cannot be undone.`, + })); + } catch { + if (generation === this.generation) { + this.showError(this.localize({ zh: '无法确认是否覆盖文件。', en: 'Could not confirm whether to overwrite the file.' })); + } + return; + } finally { + if (generation === this.generation) { + this.uploadConfirmationPending = false; + this.updateControls(); + } + } + if (!overwrite) return; + } + if (generation !== this.generation || !this.ready || this.hasTransfer() || !this.isSocketOpen()) return; + const requestId = this.createRequest('upload', path); + this.uploadState = { + generation: this.generation, + requestId, + file, + path, + sent: 0, + acknowledged: 0, + awaitingAck: false, + endSent: false, + readyForData: false, + cancelling: false, + cancelSent: false, + }; + this.showProgress('upload', file.name, 0); + this.send({ type: 'sftp_upload_start', requestId, path, size: file.size, overwrite }); + this.updateControls(); + } + + downloadSelected(): void { + const entry = this.selectedEntry(); + if (!this.ready || !entry || entry.type !== 'file' || this.hasTransfer()) return; + if (sizeExceedsLimit(entry.size)) { + this.showError(this.localize({ zh: '文件不能超过 64 MiB。', en: 'Files cannot exceed 64 MiB.' })); + return; + } + const path = joinPath(this.cwd, entry.name); + const requestId = this.createRequest('download', path); + this.downloadState = { + generation: this.generation, + requestId, + path, + name: entry.name, + expectedSize: transferSize(entry.size), + received: 0, + chunks: [], + started: false, + cancelling: false, + cancelSent: false, + }; + this.showProgress('download', entry.name, 0); + this.send({ type: 'sftp_download', requestId, path }); + this.updateControls(); + } + + cancelTransfer(): void { + this.requestTransferCancel(); + } + + async handle(data: SocketData): Promise { + await this.handleSocketData(data, this.generation); + } + + private bindControls(): void { + const signal = this.bindings.signal; + this.elements.path.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') return; + event.preventDefault(); + this.navigate(this.elements.path.value); + }, { signal }); + this.elements.back.addEventListener('click', () => this.back(), { signal }); + this.elements.up.addEventListener('click', () => this.up(), { signal }); + this.elements.home.addEventListener('click', () => this.home(), { signal }); + this.elements.refresh.addEventListener('click', () => this.refresh(), { signal }); + this.elements.upload.addEventListener('click', () => this.elements.uploadInput.click(), { signal }); + this.elements.uploadInput.addEventListener('change', () => { + const file = this.elements.uploadInput.files?.[0]; + this.elements.uploadInput.value = ''; + if (file) void this.upload(file); + }, { signal }); + this.elements.download.addEventListener('click', () => this.downloadSelected(), { signal }); + this.elements.progressCancel.addEventListener('click', () => this.cancelTransfer(), { signal }); + this.elements.mkdir.addEventListener('click', () => void this.createDirectory(), { signal }); + this.elements.rename.addEventListener('click', () => void this.renameSelected(), { signal }); + this.elements.delete.addEventListener('click', () => void this.deleteSelected(), { signal }); + this.elements.tableBody.addEventListener('click', (event) => { + const target = this.rowFromEvent(event); + if (target) this.selectRow(target, true); + }, { signal }); + this.elements.tableBody.addEventListener('focusin', (event) => { + const target = this.rowFromEvent(event); + if (target) this.selectRow(target, false); + }, { signal }); + this.elements.tableBody.addEventListener('dblclick', (event) => { + const target = this.rowFromEvent(event); + if (!target) return; + const entry = this.selectRow(target, true); + this.activateEntry(entry); + }, { signal }); + this.elements.tableBody.addEventListener('keydown', (event) => { + const target = this.rowFromEvent(event); + if (!target) return; + if (event.key === 'Enter') { + event.preventDefault(); + this.activateEntry(this.selectRow(target, false)); + return; + } + if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const current = Number(target.dataset.index); + const next = event.key === 'Home' + ? 0 + : event.key === 'End' + ? this.entries.length - 1 + : Math.max(0, Math.min(this.entries.length - 1, current + (event.key === 'ArrowUp' ? -1 : 1))); + const nextRow = this.elements.tableBody.rows.item(next); + if (nextRow) this.selectRow(nextRow, true); + }, { signal }); + } + + private async handleSocketData(data: SocketData, generation: number): Promise { + if (generation !== this.generation) return; + if (typeof data !== 'string') { + await this.handleBinary(data, generation); + return; + } + let message: JsonRecord; + try { + const parsed: unknown = JSON.parse(data); + if (!isRecord(parsed)) throw new Error('Invalid SFTP response'); + message = parsed; + } catch { + this.showError(this.localize({ zh: '文件服务返回了无效响应。', en: 'The file service returned an invalid response.' })); + return; + } + const type = typeof message.type === 'string' ? message.type : ''; + if (type === 'pong') return; + if (type === 'sftp_ready') { + this.handleReady(message); + return; + } + if (type === 'sftp_list_result') { + this.handleListResult(message); + return; + } + if (type === 'sftp_upload_ready') { + await this.handleUploadReady(message); + return; + } + if (type === 'sftp_upload_progress') { + await this.handleUploadProgress(message); + return; + } + if (type === 'sftp_upload_complete') { + this.handleUploadComplete(message); + return; + } + if (type === 'sftp_download_start') { + this.handleDownloadStart(message); + return; + } + if (type === 'sftp_download_progress') { + this.handleDownloadProgress(message); + return; + } + if (type === 'sftp_download_done') { + this.handleDownloadDone(message); + return; + } + if (type === 'sftp_upload_cancelled' || type === 'sftp_download_cancelled') { + this.handleTransferCancelled(message, type === 'sftp_upload_cancelled' ? 'upload' : 'download'); + return; + } + if (type === 'sftp_error') { + this.handleServerError(message); + return; + } + if (type === 'sftp_closed') { + this.socket?.close(1000, 'SFTP service closed'); + return; + } + if (type.endsWith('_result')) this.handleMutationResult(message); + } + + private handleReady(message: JsonRecord): void { + if (this.ready) return; + let cwd: string; + try { cwd = normalizePath(typeof message.cwd === 'string' ? message.cwd : '/'); } catch { cwd = '/'; } + this.ready = true; + this.cwd = cwd; + this.homePath = cwd; + this.history = [cwd]; + this.historyIndex = 0; + this.elements.path.value = cwd; + this.updateControls(); + this.requestList(cwd, 'none'); + } + + private requestList(path: string, historyMode: PendingRequest['historyMode'], historyIndex?: number): string | null { + if (!this.ready || !this.isSocketOpen()) return null; + let normalized: string; + try { normalized = normalizePath(path); } catch { + this.showError(this.localize({ zh: '请输入有效的绝对路径。', en: 'Enter a valid absolute path.' })); + return null; + } + if (this.activeListRequest) this.pending.delete(this.activeListRequest); + const requestId = this.createRequest('list', normalized, { historyMode, historyIndex }); + this.activeListRequest = requestId; + this.elements.loading.hidden = false; + this.elements.empty.hidden = true; + this.elements.error.hidden = true; + this.setStatus({ zh: `正在读取 ${normalized}`, en: `Loading ${normalized}` }); + this.send({ type: 'sftp_list', requestId, path: normalized }); + return requestId; + } + + private handleListResult(message: JsonRecord): void { + const requestId = typeof message.requestId === 'string' ? message.requestId : ''; + const pending = this.pending.get(requestId); + if (!pending || pending.generation !== this.generation || pending.operation !== 'list') return; + this.pending.delete(requestId); + if (requestId !== this.activeListRequest) return; + this.activeListRequest = null; + let path: string; + try { path = normalizePath(typeof message.path === 'string' ? message.path : pending.path); } catch { + this.showError(this.localize({ zh: '文件服务返回了无效路径。', en: 'The file service returned an invalid path.' })); + return; + } + if (!Array.isArray(message.entries)) { + this.showError(this.localize({ zh: '文件服务返回了无效目录列表。', en: 'The file service returned an invalid directory listing.' })); + return; + } + const entries = message.entries.map((entry) => this.parseEntry(entry)).filter((entry): entry is SFTPEntry => entry !== null); + entries.sort((left, right) => { + if (left.type === 'directory' && right.type !== 'directory') return -1; + if (left.type !== 'directory' && right.type === 'directory') return 1; + return left.name.localeCompare(right.name, this.isChinese() ? 'zh-CN' : 'en', { numeric: true, sensitivity: 'base' }); + }); + this.cwd = path; + if (pending.historyMode === 'push' && this.history[this.historyIndex] !== path) { + this.history = this.history.slice(0, this.historyIndex + 1); + this.history.push(path); + this.historyIndex = this.history.length - 1; + } else if (pending.historyMode === 'index' && pending.historyIndex !== undefined) { + this.historyIndex = pending.historyIndex; + } + this.entries = entries; + this.selectedIndex = -1; + this.elements.path.value = path; + this.elements.loading.hidden = true; + this.elements.error.hidden = true; + this.elements.empty.hidden = entries.length !== 0; + this.renderEntries(); + const truncated = message.isTruncated === true; + this.setStatus({ + zh: `${entries.length} 个项目${truncated ? '(列表已截断)' : ''}`, + en: `${entries.length} item${entries.length === 1 ? '' : 's'}${truncated ? ' (list truncated)' : ''}`, + }); + this.updateControls(); + } + + private parseEntry(value: unknown): SFTPEntry | null { + if (!isRecord(value) || typeof value.name !== 'string') return null; + let name: string; + try { name = validateName(value.name); } catch { return null; } + const rawType = typeof value.type === 'string' ? value.type.toLowerCase() : 'other'; + const type: SFTPEntry['type'] = rawType === 'dir' || rawType === 'directory' + ? 'directory' + : rawType === 'file' || rawType === 'regular' + ? 'file' + : rawType === 'link' || rawType === 'symlink' + ? 'symlink' + : 'other'; + return { + name, + type, + size: fileSizeValue(value.size) ?? 0, + mtime: typeof value.mtime === 'string' || typeof value.mtime === 'number' ? value.mtime : '', + permissions: typeof value.permissions === 'string' || typeof value.permissions === 'number' ? value.permissions : '', + uid: typeof value.uid === 'string' || typeof value.uid === 'number' ? value.uid : undefined, + gid: typeof value.gid === 'string' || typeof value.gid === 'number' ? value.gid : undefined, + owner: typeof value.owner === 'string' ? value.owner : undefined, + group: typeof value.group === 'string' ? value.group : undefined, + }; + } + + private renderEntries(): void { + this.elements.tableBody.replaceChildren(); + const fragment = document.createDocumentFragment(); + this.entries.forEach((entry, index) => { + const row = document.createElement('tr'); + row.dataset.index = String(index); + row.tabIndex = index === this.rovingIndex() ? 0 : -1; + row.setAttribute('aria-selected', String(index === this.selectedIndex)); + + const nameCell = document.createElement('td'); + const nameWrap = document.createElement('span'); + nameWrap.className = 'file-name-cell'; + const icon = document.createElement('span'); + icon.className = `file-kind-icon ${entry.type}`; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = entry.type === 'directory' ? '\u25a0' : entry.type === 'symlink' ? '\u2197' : '\u25a1'; + const name = document.createElement('span'); + name.textContent = entry.name; + nameWrap.append(icon, name); + nameCell.append(nameWrap); + row.append( + nameCell, + this.cell(entry.type === 'directory' ? '' : this.formatSize(entry.size)), + this.localizedCell(this.typeLabel(entry.type)), + this.cell(this.formatDate(entry.mtime)), + this.cell(String(entry.permissions ?? '')), + this.cell(entry.owner ?? String(entry.uid ?? '')), + this.cell(entry.group ?? String(entry.gid ?? '')), + ); + fragment.append(row); + }); + this.elements.tableBody.append(fragment); + } + + private cell(text: string): HTMLTableCellElement { + const cell = document.createElement('td'); + cell.textContent = text; + cell.title = text; + return cell; + } + + private localizedCell(copy: LocalizedText): HTMLTableCellElement { + const cell = this.cell(this.localize(copy)); + cell.dataset.i18nZh = copy.zh; + cell.dataset.i18nEn = copy.en; + return cell; + } + + private rowFromEvent(event: Event): HTMLTableRowElement | null { + const target = event.target instanceof Element ? event.target.closest('tr[data-index]') : null; + if (!target || !this.elements.tableBody.contains(target)) return null; + return target; + } + + private selectRow(target: HTMLTableRowElement, focus: boolean): SFTPEntry | null { + const index = Number(target.dataset.index); + if (!Number.isInteger(index) || !this.entries[index]) return null; + this.selectedIndex = index; + for (const row of this.elements.tableBody.rows) { + const selected = row === target; + row.setAttribute('aria-selected', String(selected)); + row.tabIndex = selected ? 0 : -1; + } + if (focus) target.focus(); + this.updateControls(); + return this.entries[index]; + } + + private rovingIndex(): number { + if (this.entries.length === 0) return -1; + return this.selectedIndex >= 0 && this.selectedIndex < this.entries.length ? this.selectedIndex : 0; + } + + private activateEntry(entry: SFTPEntry | null): void { + if (!entry) return; + if (entry.type === 'directory' || entry.type === 'symlink') { + this.navigate(joinPath(this.cwd, entry.name)); + } else { + this.downloadSelected(); + } + } + + private async createDirectory(): Promise { + if (!this.ready || this.hasTransfer()) return; + const input = await this.promptAction(this.localize({ zh: '新文件夹名称', en: 'New folder name' }), ''); + if (input === null) return; + let path: string; + try { path = joinPath(this.cwd, input); } catch { + this.showError(this.localize({ zh: '文件夹名称无效。', en: 'The folder name is invalid.' })); + return; + } + const requestId = this.createRequest('mkdir', path); + this.send({ type: 'sftp_mkdir', requestId, path }); + this.setStatus({ zh: '正在新建文件夹…', en: 'Creating folder…' }); + } + + private async renameSelected(): Promise { + const entry = this.selectedEntry(); + if (!this.ready || !entry || this.hasTransfer()) return; + const input = await this.promptAction(this.localize({ zh: '输入新名称', en: 'Enter a new name' }), entry.name); + if (input === null || input === entry.name) return; + let newPath: string; + try { newPath = joinPath(this.cwd, input); } catch { + this.showError(this.localize({ zh: '文件名无效。', en: 'The file name is invalid.' })); + return; + } + const oldPath = joinPath(this.cwd, entry.name); + const requestId = this.createRequest('rename', oldPath); + this.send({ type: 'sftp_rename', requestId, oldPath, newPath }); + this.setStatus({ zh: '正在重命名…', en: 'Renaming…' }); + } + + private async deleteSelected(): Promise { + const entry = this.selectedEntry(); + if (!this.ready || !entry || this.hasTransfer()) return; + const confirmed = await this.confirmAction(this.localize({ + zh: `确定要删除“${entry.name}”吗?此操作无法撤销。`, + en: `Delete “${entry.name}”? This cannot be undone.`, + })); + if (!confirmed) return; + const path = joinPath(this.cwd, entry.name); + const operation = entry.type === 'directory' ? 'rmdir' : 'delete'; + const requestId = this.createRequest(operation, path); + this.send({ type: operation === 'rmdir' ? 'sftp_rmdir' : 'sftp_delete', requestId, path }); + this.setStatus({ zh: '正在删除…', en: 'Deleting…' }); + } + + private handleMutationResult(message: JsonRecord): void { + const requestId = typeof message.requestId === 'string' ? message.requestId : ''; + const pending = this.pending.get(requestId); + if (!pending || pending.generation !== this.generation || !['mkdir', 'rename', 'delete', 'rmdir'].includes(pending.operation)) return; + this.pending.delete(requestId); + this.setStatus({ zh: '操作完成', en: 'Operation completed' }); + this.refresh(); + } + + private async handleUploadReady(message: JsonRecord): Promise { + const state = this.matchUpload(message); + if (!state || state.sent !== 0) return; + state.readyForData = true; + if (state.cancelling) { + this.sendUploadCancel(state); + return; + } + await this.sendNextUploadChunk(state); + } + + private async handleUploadProgress(message: JsonRecord): Promise { + const state = this.matchUpload(message); + if (!state) return; + const acknowledged = numberValue(message.loaded) ?? numberValue(message.transferred) ?? numberValue(message.received) ?? numberValue(message.bytes) ?? state.sent; + state.acknowledged = Math.min(state.file.size, Math.max(state.acknowledged, acknowledged)); + state.awaitingAck = false; + this.showProgress('upload', state.file.name, state.file.size === 0 ? 100 : (state.acknowledged / state.file.size) * 100); + if (state.cancelling) { + this.sendUploadCancel(state); + return; + } + await this.sendNextUploadChunk(state); + } + + private async sendNextUploadChunk(state: UploadState): Promise { + if (state !== this.uploadState || state.generation !== this.generation || !state.readyForData + || state.cancelling || state.awaitingAck || state.endSent || !this.isSocketOpen()) return; + if (state.sent >= state.file.size) { + state.endSent = true; + this.updateProgressLanguage(); + this.send({ type: 'sftp_upload_end', requestId: state.requestId }); + return; + } + const end = Math.min(state.file.size, state.sent + SFTP_UPLOAD_CHUNK_SIZE); + const chunk = await state.file.slice(state.sent, end).arrayBuffer(); + if (state !== this.uploadState || state.generation !== this.generation || state.cancelling || !this.isSocketOpen()) return; + this.socket!.send(chunk); + state.sent = end; + state.awaitingAck = true; + } + + private handleUploadComplete(message: JsonRecord): void { + const state = this.matchUpload(message); + if (!state) return; + this.pending.delete(state.requestId); + this.uploadState = null; + this.hideProgress(); + this.setStatus({ zh: `已上传 ${state.file.name}`, en: `Uploaded ${state.file.name}` }); + this.updateControls(); + this.refresh(); + } + + private handleDownloadStart(message: JsonRecord): void { + const state = this.matchDownload(message); + if (!state) return; + state.started = true; + const size = numberValue(message.size); + state.expectedSize = size; + if (state.cancelling) { + this.sendDownloadCancel(state); + return; + } + if (state.expectedSize !== null && state.expectedSize > MAX_SFTP_FILE_SIZE) { + state.cancelling = true; + this.setStatus({ zh: '正在取消传输…', en: 'Cancelling transfer…' }); + this.updateProgressLanguage(); + this.sendDownloadCancel(state); + } + } + + private handleDownloadProgress(message: JsonRecord): void { + const state = this.matchDownload(message); + if (!state || state.cancelling) return; + const received = numberValue(message.loaded) ?? numberValue(message.transferred) ?? numberValue(message.sent) ?? numberValue(message.bytes) ?? state.received; + const total = numberValue(message.total) ?? numberValue(message.size) ?? state.expectedSize; + const percent = total && total > 0 ? Math.min(100, (received / total) * 100) : 0; + this.showProgress('download', state.name, percent); + } + + private async handleBinary(data: Exclude, generation: number): Promise { + const state = this.downloadState; + if (!state || state.generation !== generation || generation !== this.generation) return; + let buffer: ArrayBuffer; + if (data instanceof Blob) buffer = await data.arrayBuffer(); + else if (ArrayBuffer.isView(data)) buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer; + else buffer = data; + if (state !== this.downloadState || state.generation !== this.generation) return; + if (state.cancelling) return; + if (state.received + buffer.byteLength > MAX_SFTP_FILE_SIZE) { + state.cancelling = true; + this.setStatus({ zh: '正在取消传输…', en: 'Cancelling transfer…' }); + this.updateProgressLanguage(); + this.sendDownloadCancel(state); + return; + } + state.chunks.push(buffer); + state.received += buffer.byteLength; + const total = state.expectedSize; + this.showProgress('download', state.name, total && total > 0 ? (state.received / total) * 100 : 0); + } + + private handleDownloadDone(message: JsonRecord): void { + const state = this.matchDownload(message); + if (!state) return; + if (state.cancelling) { + this.completeCancelledTransfer(state); + return; + } + const doneSize = numberValue(message.size); + const expectedSize = doneSize ?? state.expectedSize; + if (expectedSize !== null && state.received !== expectedSize) { + this.failTransfer(this.localize({ zh: '下载未完成,文件大小不匹配。', en: 'The download is incomplete: file size mismatch.' })); + return; + } + this.pending.delete(state.requestId); + this.downloadState = null; + this.saveBlob(new Blob(state.chunks), state.name); + this.hideProgress(); + this.setStatus({ zh: `已下载 ${state.name}`, en: `Downloaded ${state.name}` }); + this.updateControls(); + } + + private handleServerError(message: JsonRecord): void { + const requestId = typeof message.requestId === 'string' ? message.requestId : ''; + const pending = requestId ? this.pending.get(requestId) : undefined; + if (requestId && (!pending || pending.generation !== this.generation)) return; + if (requestId) this.pending.delete(requestId); + if (pending?.operation === 'list' && requestId !== this.activeListRequest) return; + if (requestId === this.activeListRequest) { + this.activeListRequest = null; + this.elements.loading.hidden = true; + } + const fallback = this.localize({ zh: '文件操作失败。', en: 'The file operation failed.' }); + const serverMessage = typeof message.message === 'string' && message.message ? message.message : fallback; + const transfer = requestId === this.uploadState?.requestId + ? this.uploadState + : requestId === this.downloadState?.requestId + ? this.downloadState + : null; + if (transfer) { + this.abortTransfers(true); + } + this.showError(serverMessage); + } + + private handleTransferCancelled(message: JsonRecord, operation: 'upload' | 'download'): void { + const requestId = typeof message.requestId === 'string' ? message.requestId : ''; + const state = operation === 'upload' ? this.uploadState : this.downloadState; + if (!state || state.requestId !== requestId || state.generation !== this.generation || !state.cancelling) return; + this.completeCancelledTransfer(state); + } + + private completeCancelledTransfer(state: UploadState | DownloadState): void { + if (state !== this.uploadState && state !== this.downloadState) return; + this.pending.delete(state.requestId); + this.abortTransfers(true); + this.setStatus({ zh: '传输已取消', en: 'Transfer cancelled' }); + } + + private requestTransferCancel(): void { + const upload = this.uploadState; + const transfer = upload ?? this.downloadState; + if (!transfer || transfer.cancelling || upload?.endSent) return; + transfer.cancelling = true; + this.setStatus({ zh: '正在取消传输…', en: 'Cancelling transfer…' }); + this.updateProgressLanguage(); + if (upload) this.sendUploadCancel(upload); + else this.sendDownloadCancel(transfer as DownloadState); + } + + private sendUploadCancel(state: UploadState): void { + if (state !== this.uploadState || state.cancelSent || !state.readyForData || !this.isSocketOpen()) return; + state.cancelSent = true; + this.send({ type: 'sftp_upload_cancel', requestId: state.requestId }); + } + + private sendDownloadCancel(state: DownloadState): void { + if (state !== this.downloadState || state.cancelSent || !state.started || !this.isSocketOpen()) return; + state.cancelSent = true; + this.send({ type: 'sftp_download_cancel', requestId: state.requestId }); + } + + private createRequest(operation: string, path: string, extra: Partial = {}): string { + const requestId = `${this.generation}-${++this.requestSequence}`; + this.pending.set(requestId, { generation: this.generation, operation, path, ...extra }); + return requestId; + } + + private matchUpload(message: JsonRecord): UploadState | null { + const requestId = typeof message.requestId === 'string' ? message.requestId : ''; + const state = this.uploadState; + return state && state.requestId === requestId && state.generation === this.generation ? state : null; + } + + private matchDownload(message: JsonRecord): DownloadState | null { + const requestId = typeof message.requestId === 'string' ? message.requestId : ''; + const state = this.downloadState; + return state && state.requestId === requestId && state.generation === this.generation ? state : null; + } + + private send(message: JsonRecord): void { + if (this.isSocketOpen()) this.socket!.send(JSON.stringify(message)); + } + + private isSocketOpen(): boolean { + return this.socket?.readyState === WebSocket.OPEN; + } + + private isCurrent(socket: WebSocket, generation: number): boolean { + return this.socket === socket && this.generation === generation; + } + + private hasTransfer(): boolean { + return this.uploadState !== null || this.downloadState !== null; + } + + private selectedEntry(): SFTPEntry | null { + return this.selectedIndex >= 0 ? this.entries[this.selectedIndex] ?? null : null; + } + + private abortTransfers(updateControls: boolean): void { + this.uploadState = null; + this.downloadState = null; + this.hideProgress(); + if (updateControls) this.updateControls(); + } + + private failTransfer(message: string): void { + const requestId = this.uploadState?.requestId ?? this.downloadState?.requestId; + if (requestId) this.pending.delete(requestId); + this.abortTransfers(true); + this.showError(message); + } + + private updateControls(): void { + const selected = this.selectedEntry(); + const idle = this.ready && !this.hasTransfer() && !this.uploadConfirmationPending; + this.elements.path.disabled = !this.ready; + this.elements.back.disabled = !this.ready || this.historyIndex <= 0; + this.elements.up.disabled = !this.ready || this.cwd === '/'; + this.elements.home.disabled = !this.ready || this.cwd === this.homePath; + this.elements.refresh.disabled = !this.ready; + this.elements.upload.disabled = !idle; + this.elements.mkdir.disabled = !idle; + this.elements.download.disabled = !idle || !selected || selected.type !== 'file' || sizeExceedsLimit(selected.size); + this.elements.rename.disabled = !idle || !selected; + this.elements.delete.disabled = !idle || !selected; + } + + private renderDisconnected(): void { + this.entries = []; + this.selectedIndex = -1; + this.elements.tableBody.replaceChildren(); + this.elements.loading.hidden = true; + this.elements.empty.hidden = true; + this.elements.error.hidden = true; + this.elements.path.value = '/'; + this.hideProgress(); + this.setStatus(DISCONNECTED); + this.updateControls(); + } + + private showError(message: string): void { + this.elements.loading.hidden = true; + this.elements.empty.hidden = true; + this.elements.error.textContent = message; + this.elements.error.hidden = false; + this.setStatus({ zh: message, en: message }); + this.onError?.(message); + this.updateControls(); + } + + private setStatus(copy: LocalizedText): void { + this.statusCopy = copy; + this.elements.status.dataset.i18nZh = copy.zh; + this.elements.status.dataset.i18nEn = copy.en; + const text = this.localize(copy); + this.elements.status.textContent = text; + this.onStatus?.(text); + } + + private showProgress(operation: 'upload' | 'download', name: string, percent: number): void { + this.elements.progress.dataset.operation = operation; + this.elements.progress.dataset.name = name; + this.elements.progress.hidden = false; + this.elements.progressCancel.disabled = false; + this.elements.progressBar.value = Math.max(0, Math.min(100, percent)); + this.updateProgressLanguage(); + } + + private updateProgressLanguage(): void { + if (this.elements.progress.hidden) return; + const transfer = this.uploadState ?? this.downloadState; + const operation = this.elements.progress.dataset.operation; + const name = this.elements.progress.dataset.name ?? ''; + const verb = operation === 'upload' + ? this.localize({ zh: '上传', en: 'Uploading' }) + : this.localize({ zh: '下载', en: 'Downloading' }); + this.elements.progressLabel.textContent = transfer?.cancelling + ? this.localize({ zh: `正在取消 ${name}…`, en: `Cancelling ${name}…` }) + : `${verb} ${name} ${Math.round(this.elements.progressBar.value)}%`; + this.elements.progressCancel.disabled = Boolean(transfer?.cancelling || this.uploadState?.endSent); + this.elements.progressCancel.textContent = transfer?.cancelling + ? this.localize({ zh: '取消中…', en: 'Cancelling…' }) + : this.localize({ zh: '取消', en: 'Cancel' }); + } + + private hideProgress(): void { + this.elements.progress.hidden = true; + this.elements.progressBar.value = 0; + this.elements.progressLabel.textContent = ''; + this.elements.progressCancel.disabled = false; + this.elements.progressCancel.textContent = this.localize({ zh: '取消', en: 'Cancel' }); + delete this.elements.progress.dataset.operation; + delete this.elements.progress.dataset.name; + } + + private formatSize(bytes: number | string): string { + if (typeof bytes === 'string') return sizeExceedsLimit(bytes) ? '> 64 MiB' : this.formatSize(Number(bytes)); + if (bytes < 1024) return `${bytes} B`; + const units = ['KiB', 'MiB', 'GiB']; + let value = bytes; + let unit = -1; + do { value /= 1024; unit++; } while (value >= 1024 && unit < units.length - 1); + return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`; + } + + private formatDate(value: string | number): string { + if (value === '') return ''; + const numeric = typeof value === 'number' ? (value < 10_000_000_000 ? value * 1000 : value) : value; + const date = new Date(numeric); + if (Number.isNaN(date.getTime())) return String(value); + return new Intl.DateTimeFormat(this.isChinese() ? 'zh-CN' : 'en', { + year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, + }).format(date); + } + + private typeLabel(type: SFTPEntry['type']): LocalizedText { + if (type === 'directory') return { zh: '文件夹', en: 'Folder' }; + if (type === 'file') return { zh: '文件', en: 'File' }; + if (type === 'symlink') return { zh: '链接', en: 'Link' }; + return { zh: '其他', en: 'Other' }; + } + + private saveBlob(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = name; + link.hidden = true; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + private localize(copy: LocalizedText): string { + return this.isChinese() ? copy.zh : copy.en; + } + + private isChinese(): boolean { + return this.getLanguage().toLowerCase().startsWith('zh'); + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 7e882e1..e181f58 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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; + url?: string; } interface WSSHOptions { @@ -110,6 +112,8 @@ const LANGUAGE_STORAGE_KEY = 'workers-webssh.language'; const MAX_KEY_BYTES = 65_536; const MAX_LEGACY_PASSWORD_BASE64_LENGTH = 16_384; const PING_INTERVAL_MS = 25_000; +const CLIENT_CLOSE_SESSION_ERROR = 4000; +const CLIENT_CLOSE_PROTOCOL_ERROR = 4002; function loadLanguage(): Language { try { @@ -181,6 +185,7 @@ const EVENT_LABELS: Record = { error: ['错误', 'error'], debug: ['调试', 'debug'], 'host-key': ['主机密钥', 'host key'], + sftp: ['文件管理', 'files'], }; const SERVER_EVENT_MESSAGES: Record = { @@ -285,6 +290,8 @@ const ui = { clearTerminal: element('clear-terminal'), fullscreenTerminal: element('fullscreen-terminal'), eventMessage: element('event-message'), + fileManagerTab: element('file-manager-tab'), + fileManagerPanel: element('file-manager-panel'), eventToggle: element('event-toggle'), eventLog: element('event-log'), toastRegion: element('toast-region'), @@ -326,6 +333,9 @@ function applyLanguage(language: Language, persist = false): void { for (const node of document.querySelectorAll('[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('[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('[data-i18n-content-zh][data-i18n-content-en]')) { node.setAttribute('content', language === 'zh-CN' ? node.dataset.i18nContentZh! : node.dataset.i18nContentEn!); } @@ -348,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. */ } @@ -386,6 +397,7 @@ let keyFileReadGeneration = 0; const latestHistoryMutation = new Map(); const panelMedia = window.matchMedia('(max-width: 760px)'); let panelOpen = !panelMedia.matches; +let fileManager: FileManager; const terminal = new Terminal({ allowProposedApi: false, @@ -404,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 { return { @@ -1193,6 +1210,29 @@ function sendHostKeyDecision(accept: boolean): void { : bilingual('主机密钥已拒绝。', 'Host key rejected.'), 'host-key', !accept); } +type WorkspaceTab = 'files' | 'log'; + +function setWorkspaceTab(tab: WorkspaceTab, focus = false): void { + const filesActive = tab === 'files'; + ui.fileManagerPanel.hidden = !filesActive; + ui.eventLog.hidden = filesActive; + ui.fileManagerTab.setAttribute('aria-selected', String(filesActive)); + ui.eventToggle.setAttribute('aria-selected', String(!filesActive)); + ui.eventToggle.setAttribute('aria-expanded', String(!filesActive)); + ui.fileManagerTab.tabIndex = filesActive ? 0 : -1; + ui.eventToggle.tabIndex = filesActive ? -1 : 0; + if (!filesActive) requestAnimationFrame(() => { ui.eventLog.scrollTop = ui.eventLog.scrollHeight; }); + if (focus) (filesActive ? ui.fileManagerTab : ui.eventToggle).focus(); + requestAnimationFrame(() => fitTerminal(true)); +} + +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); @@ -1231,6 +1271,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; @@ -1249,7 +1303,7 @@ function handleServerMessage(message: ServerMessage): void { || (expected !== '' && !SSH_FINGERPRINT_RE.test(expected)) || (message.expectedFingerprint !== undefined && message.expectedFingerprint !== currentExpectedFingerprint)) { event(bilingual('收到无效的主机密钥消息。', 'Received an invalid host key message.'), 'protocol', true); - socket?.close(1002, 'Invalid host key message'); + socket?.close(CLIENT_CLOSE_PROTOCOL_ERROR, 'Invalid host key message'); return; } ui.metricHostKey.textContent = keyType.replace('ssh-', '').replace('ecdsa-sha2-', ''); @@ -1257,7 +1311,7 @@ function handleServerMessage(message: ServerMessage): void { const trust = classifyHostKey(expected, fingerprint); if (message.trusted !== (trust === 'matched')) { event(bilingual('收到不一致的主机密钥信任消息。', 'Received an inconsistent trusted host key message.'), 'protocol', true); - socket?.close(1002, 'Invalid trusted host key message'); + socket?.close(CLIENT_CLOSE_PROTOCOL_ERROR, 'Invalid trusted host key message'); return; } if (trust === 'matched') { @@ -1309,10 +1363,24 @@ function handleServerMessage(message: ServerMessage): void { async function handleSocketData(data: string | ArrayBuffer | Blob, activeSocket: WebSocket, generation: number): Promise { if (socket !== activeSocket || generation !== connectGeneration) return; if (typeof data === 'string') { + let parsed: unknown; try { - handleServerMessage(JSON.parse(data) as ServerMessage); + parsed = JSON.parse(data); } catch { event(bilingual('已忽略无效的控制帧。', 'Ignored an invalid control frame.'), 'protocol', true); + return; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + event(bilingual('已忽略无效的控制帧。', 'Ignored an invalid control frame.'), 'protocol', true); + return; + } + try { + handleServerMessage(parsed as ServerMessage); + } catch { + event(bilingual('处理服务器控制消息时发生错误。', 'Failed to process the server control message.'), 'protocol', true); + if (activeSocket.readyState < WebSocket.CLOSING) { + activeSocket.close(CLIENT_CLOSE_PROTOCOL_ERROR, 'Control message handling failed'); + } } return; } @@ -1336,12 +1404,13 @@ function failActiveConnection(activeSocket: WebSocket | null, closeReason: strin currentExpectedFingerprint = ''; currentRememberedFingerprint = ''; stopTimers(); + fileManager.reset(); clearHostKeyPrompt(); invalidateHistoryPasswordLoad(); currentSessionSubtitle = displayReason; ui.sessionSubtitle.textContent = localize(displayReason); setState('error'); - if (activeSocket && activeSocket.readyState < WebSocket.CLOSING) activeSocket.close(1011, closeReason); + if (activeSocket && activeSocket.readyState < WebSocket.CLOSING) activeSocket.close(CLIENT_CLOSE_SESSION_ERROR, closeReason); } async function issueTicket(signal: AbortSignal): Promise<{ ticket: string; sessionId: string }> { @@ -1477,6 +1546,7 @@ async function connect(): Promise { currentExpectedFingerprint = ''; currentRememberedFingerprint = ''; stopTimers(); + fileManager.reset(); clearHostKeyPrompt(); invalidateHistoryPasswordLoad(); const wasActive = connectionState === 'connected'; @@ -1519,6 +1589,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected const activeSocket = socket; socket = null; stopTimers(); + fileManager.reset(); clearHostKeyPrompt(); invalidateHistoryPasswordLoad(); currentExpectedFingerprint = ''; @@ -1776,11 +1847,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', () => setWorkspaceTab('files')); +ui.eventToggle.addEventListener('click', () => setWorkspaceTab('log')); +ui.fileManagerTab.addEventListener('keydown', handleWorkspaceTabKey); +ui.eventToggle.addEventListener('keydown', handleWorkspaceTabKey); ui.languageToggle.addEventListener('click', () => { applyLanguage(currentLanguage === 'zh-CN' ? 'en' : 'zh-CN', true); renderProfiles(); @@ -1802,7 +1872,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); @@ -1822,6 +1895,7 @@ async function initialize(): Promise { setPanelOpen(panelOpen); setAuthMethod('password'); setState('idle'); + setWorkspaceTab('files'); 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'); @@ -1842,5 +1916,6 @@ void initialize().catch(() => { setPanelOpen(panelOpen); setAuthMethod('password'); setState('idle'); + setWorkspaceTab('files'); initializeCompatibilityAPI(); }); diff --git a/frontend/src/style.css b/frontend/src/style.css index 9092986..35f71c3 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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,7 +953,7 @@ button:disabled { display: grid; min-width: 0; min-height: 0; - grid-template-rows: 42px minmax(0, 1fr) 34px auto; + grid-template-rows: minmax(0, 2fr) minmax(0, 1fr); overflow: hidden; border: 1px solid var(--border); border-radius: 12px; @@ -950,6 +961,14 @@ button:disabled { box-shadow: var(--shadow); } +.terminal-pane { + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: 42px minmax(0, 1fr); + overflow: hidden; +} + .terminal-toolbar { display: flex; align-items: center; @@ -1083,9 +1102,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 +1115,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 +1668,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 +1769,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) { diff --git a/src/backend/durable-object.ts b/src/backend/durable-object.ts index 34ce160..45bf00f 100644 --- a/src/backend/durable-object.ts +++ b/src/backend/durable-object.ts @@ -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; } +interface SFTPAttachToken { + mainWebSocket: WebSocket; + attachUrl: string; + expiresAt: number; + timeout: ReturnType; + 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(); private readonly pendingConnections = new Map(); private readonly deadlines = new Map>(); + private readonly sftpAttachTokens = new Map(); + private readonly sftpTokenByMainWebSocket = new Map(); + private readonly sftpSessions = new Map(); + private readonly sftpOwners = new Map(); + private readonly sftpWebSocketsByMain = new Map>(); constructor(state: DurableObjectState, env: Env) { this.state = state; this.env = env; for (const ws of state.getWebSockets()) { const attachment = ws.deserializeAttachment() as Attachment | null; - if (attachment?.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 { + 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 { this.cleanup(ws); } - async webSocketError(ws: WebSocket): Promise { this.cleanup(ws); } + async webSocketClose(ws: WebSocket): Promise { + if (this.isSFTPWebSocket(ws)) this.cleanupSFTPWebSocket(ws); + else this.cleanup(ws); + } + + async webSocketError(ws: WebSocket): Promise { + if (this.isSFTPWebSocket(ws)) this.cleanupSFTPWebSocket(ws); + else this.cleanup(ws); + } async alarm(): Promise { 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(); + 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 { 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); diff --git a/src/backend/session.ts b/src/backend/session.ts index cbba221..e28ec65 100644 --- a/src/backend/session.ts +++ b/src/backend/session.ts @@ -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 | 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([[0, this.shellChannel]]); private readonly config: SSHConnectionConfig; private readonly ws: WebSocket; private readonly socket: Socket; @@ -87,7 +97,17 @@ export class SSHSession { private serverSigAlgs: string[] = []; private authRequestSent = false; private passwordAuthMethod: 'password' | 'keyboard-interactive' = 'password'; + 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 = Promise.resolve(); + private sftpQueuedUploadBytes = 0; + private pendingSFTPChannelOpen: PendingSFTPChannelOpen | null = null; private ignoreNextKexPacket = false; private inputQueue: Uint8Array[] = []; private queueHeadOffset = 0; @@ -158,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; @@ -490,8 +517,26 @@ export class SSHSession { if (this.config.authMethod !== 'password' || this.passwordAuthMethod !== 'keyboard-interactive' || !this.authRequestSent) { throw new Error('Unexpected SSH keyboard-interactive challenge'); } - const promptCount = this.validateKeyboardInteractiveRequest(payload); - const response = SSHAuth.buildKeyboardInteractiveResponse(this.config.password!, promptCount); + this.keyboardInteractiveRounds++; + if (this.keyboardInteractiveRounds > 16) throw new Error('Too many SSH keyboard-interactive challenge rounds'); + const challenge = SSHAuth.parseKeyboardInteractiveChallenge(payload); + let responses: string[]; + if (challenge.prompts.length === 0) { + responses = []; + } else { + if (this.keyboardInteractivePasswordSent || this.config.password === undefined) { + throw new Error('SSH keyboard-interactive requested credentials more than once'); + } + responses = SSHAuth.passwordResponsesForChallenge(challenge, this.config.password, { + username: this.config.username, + host: this.config.host, + }); + } + const response = SSHAuth.buildKeyboardInteractiveResponse(responses); + if (responses.length > 0) { + this.keyboardInteractivePasswordSent = true; + this.config.password = undefined; + } await this.sendEncrypted(response); return; } @@ -503,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) { @@ -523,60 +568,132 @@ export class SSHSession { } private async handleChannel(type: number, payload: Uint8Array): Promise { + 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; } } @@ -592,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\/ssh\/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 { + 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; + try { + const parsed = JSON.parse(message) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(); + value = parsed as Record; + } 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): Promise { + 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 { + 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 { + 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): Promise { + 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 { + 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'); @@ -610,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) { @@ -627,12 +962,12 @@ export class SSHSession { private async resize(cols: unknown, rows: unknown): Promise { 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 { - const amount = this.channel.takeLocalWindowAdjustment(LOCAL_WINDOW_THRESHOLD); - if (amount !== null) await this.sendEncrypted(this.channel.buildWindowAdjust(amount)); + private async adjustLocalWindow(channel: SSHChannel): Promise { + const amount = channel.takeLocalWindowAdjustment(LOCAL_WINDOW_THRESHOLD); + if (amount !== null) await this.sendEncrypted(channel.buildWindowAdjust(amount)); } private async handleGlobalRequest(payload: Uint8Array): Promise { @@ -681,11 +1016,11 @@ export class SSHSession { }); } - private async sendChannelData(chunk: ChannelDataChunk): Promise { + private async sendChannelData(channel: SSHChannel, chunk: ChannelDataChunk): Promise { 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); }); @@ -784,26 +1119,6 @@ export class SSHSession { return methods ? methods.split(',') : []; } - private validateKeyboardInteractiveRequest(payload: Uint8Array): number { - let offset = 1; - offset = this.readString(payload, offset).next; // name - offset = this.readString(payload, offset).next; // instruction - offset = this.readString(payload, offset).next; // language tag - if (offset + 4 > payload.length) throw new Error('Malformed SSH keyboard-interactive challenge'); - const promptCount = readUint32(payload, offset); - offset += 4; - if (promptCount !== 1) throw new Error('SSH keyboard-interactive authentication requires exactly one prompt'); - const prompt = this.readString(payload, offset); - offset = prompt.next; - if (offset >= payload.length || payload[offset] > 1 || offset + 1 !== payload.length) { - throw new Error('Malformed SSH keyboard-interactive prompt'); - } - if (!SSHAuth.isSupportedPasswordPrompt(prompt.value, payload[offset] === 1)) { - throw new Error('SSH keyboard-interactive prompt is not a supported password prompt'); - } - return promptCount; - } - private isHostKeyAlgorithmCompatible(negotiated: string | null, keyType: string): boolean { if (!negotiated) return false; if (negotiated === keyType) return true; diff --git a/src/backend/sftp-handler.ts b/src/backend/sftp-handler.ts new file mode 100644 index 0000000..95f3da1 --- /dev/null +++ b/src/backend/sftp-handler.ts @@ -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; +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 { + 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 { + try { + this.assertReady(); + this.validatePath(path); + const resolvedPath = await this.client.realpath(path || '.'); + const handle = await this.client.openDir(resolvedPath); + const entries: Record[] = []; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }); + } +} diff --git a/src/ssh/auth-password.ts b/src/ssh/auth-password.ts index 4eaaf31..c08f71e 100644 --- a/src/ssh/auth-password.ts +++ b/src/ssh/auth-password.ts @@ -1,5 +1,23 @@ const SSH_MSG_USERAUTH_REQUEST = 50; const SSH_MSG_USERAUTH_INFO_RESPONSE = 61; +const MAX_KEYBOARD_INTERACTIVE_PROMPTS = 16; + +export interface KeyboardInteractivePrompt { + text: string; + echo: boolean; +} + +export interface KeyboardInteractiveChallenge { + name: string; + instruction: string; + language: string; + prompts: KeyboardInteractivePrompt[]; +} + +export interface PasswordPromptIdentity { + username: string; + host: string; +} function concat(...arrays: Uint8Array[]): Uint8Array { const result = new Uint8Array(arrays.reduce((total, value) => total + value.length, 0)); @@ -18,6 +36,25 @@ function encodeString(value: string): Uint8Array { return concat(length, data); } +function readUint32(data: Uint8Array, offset: number): number { + if (offset + 4 > data.length) throw new Error('Malformed SSH keyboard-interactive challenge'); + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset, false); +} + +function readString(data: Uint8Array, offset: number): { value: string; next: number } { + const length = readUint32(data, offset); + const start = offset + 4; + if (length > data.length - start) throw new Error('Malformed SSH keyboard-interactive challenge'); + try { + return { + value: new TextDecoder('utf-8', { fatal: true }).decode(data.subarray(start, start + length)), + next: start + length, + }; + } catch { + throw new Error('Malformed SSH keyboard-interactive text'); + } +} + export function buildPasswordAuthRequest(username: string, password: string): Uint8Array { return concat( new Uint8Array([SSH_MSG_USERAUTH_REQUEST]), @@ -40,15 +77,63 @@ export function buildKeyboardInteractiveAuthRequest(username: string): Uint8Arra ); } -export function buildKeyboardInteractiveResponse(password: string, promptCount: number): Uint8Array { - if (promptCount !== 1) throw new Error('SSH keyboard-interactive authentication requires exactly one prompt'); +export function parseKeyboardInteractiveChallenge(payload: Uint8Array): KeyboardInteractiveChallenge { + if (payload[0] !== 60) throw new Error('Malformed SSH keyboard-interactive message type'); + let offset = 1; + const name = readString(payload, offset); offset = name.next; + const instruction = readString(payload, offset); offset = instruction.next; + const language = readString(payload, offset); offset = language.next; + const promptCount = readUint32(payload, offset); offset += 4; + if (promptCount > MAX_KEYBOARD_INTERACTIVE_PROMPTS) { + throw new Error('SSH keyboard-interactive challenge has too many prompts'); + } + const prompts: KeyboardInteractivePrompt[] = []; + for (let index = 0; index < promptCount; index++) { + const prompt = readString(payload, offset); offset = prompt.next; + if (prompt.value.length === 0 || offset >= payload.length || payload[offset] > 1) { + throw new Error('Malformed SSH keyboard-interactive prompt'); + } + prompts.push({ text: prompt.value, echo: payload[offset] === 1 }); + offset++; + } + if (offset !== payload.length) throw new Error('Malformed SSH keyboard-interactive trailing data'); + return { name: name.value, instruction: instruction.value, language: language.value, prompts }; +} + +export function passwordResponsesForChallenge( + challenge: KeyboardInteractiveChallenge, + password: string, + identity: PasswordPromptIdentity, +): string[] { + if (challenge.prompts.length === 0) return []; + if (challenge.prompts.length === 1 && isSupportedPasswordPrompt(challenge, identity)) { + return [password]; + } + throw new Error(`SSH keyboard-interactive challenge is not a supported password prompt (${challenge.prompts.length} prompts)`); +} + +export function buildKeyboardInteractiveResponse(responses: string[]): Uint8Array { + const count = new Uint8Array(4); + new DataView(count.buffer).setUint32(0, responses.length, false); return concat( new Uint8Array([SSH_MSG_USERAUTH_INFO_RESPONSE]), - new Uint8Array([0, 0, 0, 1]), - encodeString(password), + count, + ...responses.map(encodeString), ); } -export function isSupportedPasswordPrompt(prompt: string, echo: boolean): boolean { - return !echo && /(?:password|passphrase|密码|口令)/i.test(prompt); +export function isSupportedPasswordPrompt( + challenge: KeyboardInteractiveChallenge, + identity: PasswordPromptIdentity, +): boolean { + if (challenge.prompts.length !== 1 || challenge.prompts[0].echo) return false; + // Automatic credential reuse is deliberately limited to an unambiguous, + // context-free password request for this exact SSH target. + if (challenge.name.trim() || challenge.instruction.trim()) return false; + const prompt = challenge.prompts[0].text; + if (/[\u0000-\u001f\u007f-\u009f]/u.test(prompt)) return false; + const normalized = prompt.trim(); + if (/^(?:password|密码|口令)\s*[::]$/iu.test(normalized)) return true; + const match = /^password for (.+)@(.+):$/iu.exec(normalized); + return match !== null && match[1] === identity.username && match[2].toLowerCase() === identity.host.toLowerCase(); } diff --git a/src/ssh/auth.ts b/src/ssh/auth.ts index 59fd821..1afd762 100644 --- a/src/ssh/auth.ts +++ b/src/ssh/auth.ts @@ -5,6 +5,10 @@ import { buildKeyboardInteractiveResponse, buildPasswordAuthRequest, isSupportedPasswordPrompt, + parseKeyboardInteractiveChallenge, + passwordResponsesForChallenge, + type KeyboardInteractiveChallenge, + type PasswordPromptIdentity, } from './auth-password'; // SSH key type constants @@ -40,12 +44,24 @@ export class SSHAuth { return buildKeyboardInteractiveAuthRequest(username); } - static buildKeyboardInteractiveResponse(password: string, promptCount: number): Uint8Array { - return buildKeyboardInteractiveResponse(password, promptCount); + static parseKeyboardInteractiveChallenge(payload: Uint8Array): KeyboardInteractiveChallenge { + return parseKeyboardInteractiveChallenge(payload); } - static isSupportedPasswordPrompt(prompt: string, echo: boolean): boolean { - return isSupportedPasswordPrompt(prompt, echo); + static passwordResponsesForChallenge( + challenge: KeyboardInteractiveChallenge, + password: string, + identity: PasswordPromptIdentity, + ): string[] { + return passwordResponsesForChallenge(challenge, password, identity); + } + + static buildKeyboardInteractiveResponse(responses: string[]): Uint8Array { + return buildKeyboardInteractiveResponse(responses); + } + + static isSupportedPasswordPrompt(challenge: KeyboardInteractiveChallenge, identity: PasswordPromptIdentity): boolean { + return isSupportedPasswordPrompt(challenge, identity); } /** Build a signed public-key authentication request (RFC 4252 section 7). */ diff --git a/src/ssh/channel.ts b/src/ssh/channel.ts index 64a3e3e..ebccb92 100644 --- a/src/ssh/channel.ts +++ b/src/ssh/channel.ts @@ -11,6 +11,7 @@ import { encodeString, readUint32, writeUint32 } from './utils'; const SESSION_FIELD = encodeString('session'); const PTY_REQ_FIELD = encodeString('pty-req'); const SHELL_FIELD = encodeString('shell'); +const 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) { diff --git a/src/ssh/sftp-types.ts b/src/ssh/sftp-types.ts new file mode 100644 index 0000000..f93dd21 --- /dev/null +++ b/src/ssh/sftp-types.ts @@ -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; +} + +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 = { + 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; diff --git a/src/ssh/sftp.ts b/src/ssh/sftp.ts new file mode 100644 index 0000000..2c3beab --- /dev/null +++ b/src/ssh/sftp.ts @@ -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 { + readonly id: number; + readonly timer: ReturnType; + readonly parse: (type: number, reader: Reader) => T; + readonly resolve: (value: T | PromiseLike) => void; + readonly reject: (reason: unknown) => void; +} + +type SendData = (packet: Uint8Array) => void | Promise; +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>(); + 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 | null = null; + private initializationResolve: ((info: SFTPVersionInfo) => void) | null = null; + private initializationReject: ((reason: unknown) => void) | null = null; + private initializationTimer: ReturnType | 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 { + 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((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 { + 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 { + 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 { + 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 { + return this.statusRequest(SFTP_PACKET_TYPES.CLOSE, [encodeString(handle, 'handle')]); + } + + stat(path: string): Promise { + 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 { + 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 { + 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 { + return this.statusRequest(SFTP_PACKET_TYPES.WRITE, [ + encodeString(handle, 'handle'), + encodeUint64(offset, 'file offset'), + encodeString(data, 'file data'), + ]); + } + + removeFile(path: string): Promise { + return this.statusRequest(SFTP_PACKET_TYPES.REMOVE, [encodeString(path, 'path')]); + } + + mkdir(path: string, attrs?: SFTPAttrsInput): Promise { + return this.statusRequest(SFTP_PACKET_TYPES.MKDIR, [ + encodeString(path, 'path'), + encodeAttrs(attrs, this.maxExtendedAttributes), + ]); + } + + rmdir(path: string): Promise { + return this.statusRequest(SFTP_PACKET_TYPES.RMDIR, [encodeString(path, 'path')]); + } + + rename(oldPath: string, newPath: string): Promise { + return this.statusRequest(SFTP_PACKET_TYPES.RENAME, [ + encodeString(oldPath, 'old path'), + encodeString(newPath, 'new path'), + ]); + } + + private request( + type: number, + fields: readonly Uint8Array[], + parse: (type: number, reader: Reader, requestId: number) => T, + ): Promise { + this.assertState('ready'); + const id = this.allocateRequestId(); + const body = join([new Uint8Array([type]), encodeUint32(id, 'request ID'), ...fields]); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.pending.delete(id)) reject(new SFTPTimeoutError(id)); + }, this.requestTimeoutMs); + const pending: PendingRequest = { id, timer, parse: (responseType, reader) => parse(responseType, reader, id), resolve, reject }; + this.pending.set(id, pending as PendingRequest); + 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 { + 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(); + 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 | 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).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}`); + } + } +} diff --git a/src/worker.ts b/src/worker.ts index 8044c4d..c8ef1e4 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -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 { 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 { async function sshUpgrade(request: Request, env: Env): Promise { 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 { 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 { + 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 { 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)); diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..66b30ec --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,142 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildKeyboardInteractiveAuthRequest, + buildKeyboardInteractiveResponse, + isSupportedPasswordPrompt, + parseKeyboardInteractiveChallenge, + passwordResponsesForChallenge, +} from '../src/ssh/auth-password.ts'; + +const IDENTITY = { username: 'alice', host: 'example.com' }; + +interface ChallengeFields { + name?: string; + instruction?: string; + language?: string; +} + +function encodeString(value: string): Uint8Array { + const bytes = new TextEncoder().encode(value); + const result = new Uint8Array(4 + bytes.length); + new DataView(result.buffer).setUint32(0, bytes.length, false); + result.set(bytes, 4); + return result; +} + +function challenge(prompts: Array<{ text: string; echo: boolean }>, fields: ChallengeFields = {}): Uint8Array { + const count = new Uint8Array(4); + new DataView(count.buffer).setUint32(0, prompts.length, false); + const parts = [ + new Uint8Array([60]), + encodeString(fields.name ?? ''), + encodeString(fields.instruction ?? ''), + encodeString(fields.language ?? ''), + count, + ...prompts.flatMap((prompt) => [encodeString(prompt.text), new Uint8Array([prompt.echo ? 1 : 0])]), + ]; + const result = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + let offset = 0; + for (const part of parts) { result.set(part, offset); offset += part.length; } + return result; +} + +function readString(data: Uint8Array, offset: number): { value: string; next: number } { + const length = readUint32(data, offset); + const start = offset + 4; + return { value: new TextDecoder().decode(data.subarray(start, start + length)), next: start + length }; +} + +function readUint32(data: Uint8Array, offset: number): number { + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset, false); +} + +test('builds an RFC 4256 keyboard-interactive request without the password', () => { + const request = buildKeyboardInteractiveAuthRequest('coss'); + assert.equal(request[0], 50); + let field = readString(request, 1); + assert.equal(field.value, 'coss'); + field = readString(request, field.next); + assert.equal(field.value, 'ssh-connection'); + field = readString(request, field.next); + assert.equal(field.value, 'keyboard-interactive'); + field = readString(request, field.next); + assert.equal(field.value, ''); + field = readString(request, field.next); + assert.equal(field.value, ''); + assert.equal(field.next, request.length); + assert.equal(new TextDecoder().decode(request).includes('secret'), false); +}); + +test('answers a password prompt', () => { + const parsed = parseKeyboardInteractiveChallenge(challenge([{ + text: 'Password for alice@example.com:', + echo: false, + }])); + const response = buildKeyboardInteractiveResponse(passwordResponsesForChallenge(parsed, 'test-secret', IDENTITY)); + assert.equal(response[0], 61); + assert.equal(readUint32(response, 1), 1); + const answer = readString(response, 5); + assert.equal(answer.value, 'test-secret'); + assert.equal(answer.next, response.length); +}); + +test('acknowledges zero-prompt keyboard-interactive notices', () => { + const parsed = parseKeyboardInteractiveChallenge(challenge([], { instruction: 'Authentication successful' })); + assert.deepEqual(parsed.prompts, []); + const response = buildKeyboardInteractiveResponse(passwordResponsesForChallenge(parsed, 'test-secret', IDENTITY)); + assert.equal(response[0], 61); + assert.equal(readUint32(response, 1), 0); + assert.equal(response.length, 5); +}); + +test('rejects multiple or non-password prompts', () => { + const multiple = parseKeyboardInteractiveChallenge(challenge([ + { text: 'Password:', echo: false }, + { text: 'Verification code:', echo: false }, + ])); + assert.throws(() => passwordResponsesForChallenge(multiple, 'test-secret', IDENTITY), /2 prompts/); + const otp = parseKeyboardInteractiveChallenge(challenge([{ text: 'Verification code:', echo: false }])); + assert.throws(() => passwordResponsesForChallenge(otp, 'test-secret', IDENTITY), /not a supported password prompt/); +}); + +test('only auto-answers hidden password prompts', () => { + const supported = (text: string, echo = false, fields: ChallengeFields = {}) => isSupportedPasswordPrompt( + parseKeyboardInteractiveChallenge(challenge([{ text, echo }], fields)), + IDENTITY, + ); + assert.equal(supported('Password:'), true); + assert.equal(supported('密码:'), true); + assert.equal(supported('Password for alice@example.com:'), true); + assert.equal(supported('password for alice@EXAMPLE.COM:'), true); + assert.equal(supported('Password for mallory@example.com:'), false); + assert.equal(supported('Password:', true), false); + assert.equal(supported('Verification code:'), false); + assert.equal(supported('Do not enter your password; enter OTP:'), false); + assert.equal(supported('Enter new password:'), false); + assert.equal(supported('Password + verification code:'), false); + assert.equal(supported('Passphrase for key:'), false); + assert.equal(supported('Password:', false, { instruction: 'Enter your one-time code' }), false); + assert.equal(supported('Password:', false, { name: 'Second factor' }), false); +}); + +test('strictly validates keyboard-interactive challenge framing', () => { + assert.throws(() => parseKeyboardInteractiveChallenge(challenge([{ text: '', echo: false }])), /Malformed/); + + const badBoolean = challenge([{ text: 'Password:', echo: false }]); + badBoolean[badBoolean.length - 1] = 2; + assert.throws(() => parseKeyboardInteractiveChallenge(badBoolean), /Malformed/); + + const invalidUtf8 = challenge([], { name: 'x' }); + invalidUtf8[5] = 0xff; + assert.throws(() => parseKeyboardInteractiveChallenge(invalidUtf8), /text/); + + const valid = challenge([{ text: 'Password:', echo: false }]); + const trailing = new Uint8Array(valid.length + 1); + trailing.set(valid); + assert.throws(() => parseKeyboardInteractiveChallenge(trailing), /trailing data/); + assert.throws(() => parseKeyboardInteractiveChallenge(valid.subarray(0, valid.length - 1)), /Malformed/); + + const tooMany = Array.from({ length: 17 }, () => ({ text: 'Password:', echo: false })); + assert.throws(() => parseKeyboardInteractiveChallenge(challenge(tooMany)), /too many prompts/); +}); diff --git a/tests/host-key.test.ts b/tests/host-key.test.ts new file mode 100644 index 0000000..133977e --- /dev/null +++ b/tests/host-key.test.ts @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + classifyHostKey as classifyBrowserHostKey, + SSH_FINGERPRINT_RE, +} from '../frontend/src/host-key.ts'; +import { classifyHostKey as classifyWorkerHostKey } from '../src/ssh/host-key.ts'; + +const OLD = 'SHA256:McWyGS5cvgt9EvFLQxxLOTPDqAY1MmKx3iAhgXUpEiY'; +const CURRENT = 'SHA256:xs49vJHNJGmereADhVfIZMt1xdeY04wYkj5FpDpLR9c'; + +test('validates OpenSSH SHA-256 fingerprints', () => { + assert.equal(SSH_FINGERPRINT_RE.test(OLD), true); + assert.equal(SSH_FINGERPRINT_RE.test(CURRENT), true); + assert.equal(SSH_FINGERPRINT_RE.test(`${CURRENT}=`), false); + assert.equal(SSH_FINGERPRINT_RE.test('SHA256:not-a-fingerprint'), false); +}); + +test('browser prompts only for first-seen or changed host keys', () => { + assert.equal(classifyBrowserHostKey('', CURRENT), 'first-seen'); + assert.equal(classifyBrowserHostKey(CURRENT, CURRENT), 'matched'); + assert.equal(classifyBrowserHostKey(OLD, CURRENT), 'changed'); +}); + +test('worker never treats a changed host key as already trusted', () => { + assert.equal(classifyWorkerHostKey(undefined, CURRENT), 'first-seen'); + assert.equal(classifyWorkerHostKey(CURRENT, CURRENT), 'trusted'); + assert.equal(classifyWorkerHostKey(OLD, CURRENT), 'changed'); +}); + diff --git a/tsconfig.worker.json b/tsconfig.worker.json index 1b142d1..88facf0 100644 --- a/tsconfig.worker.json +++ b/tsconfig.worker.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, "lib": ["ES2022", "WebWorker"], "types": ["@cloudflare/workers-types"], "strict": true, From 8d616cd87d0ec1400e2b16177473d8a3862071f9 Mon Sep 17 00:00:00 2001 From: cmliu Date: Wed, 29 Jul 2026 10:59:27 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20.gitignore?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=20tests/=20=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E4=BB=A5=E6=8E=92=E9=99=A4=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 13b6c96..a889fc5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage/ .env* !.env.example .workbuddy/ +tests/ \ No newline at end of file From 8cb4d88b3949e9593fc341f27fcc59efdd51a98f Mon Sep 17 00:00:00 2001 From: cmliu Date: Wed, 29 Jul 2026 11:03:59 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20SFTP=20?= =?UTF-8?q?=E9=99=84=E5=8A=A0=20URL=20=E7=9A=84=E6=AD=A3=E5=88=99=E8=A1=A8?= =?UTF-8?q?=E8=BE=BE=E5=BC=8F=E5=8C=B9=E9=85=8D=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/session.ts b/src/backend/session.ts index e28ec65..76bdd01 100644 --- a/src/backend/session.ts +++ b/src/backend/session.ts @@ -714,7 +714,7 @@ export class SSHSession { } setSFTPAttachUrl(url: string): void { - if (!/^\/api\/ssh\/sftp\?/.test(url)) throw new Error('Invalid SFTP attach URL'); + if (!/^\/api\/sftp\?/.test(url)) throw new Error('Invalid SFTP attach URL'); this.sftpAttachUrl = url; } From b8b2f63073f32027d8f783fc79430c72296d4e63 Mon Sep 17 00:00:00 2001 From: cmliu Date: Wed, 29 Jul 2026 11:24:12 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E9=80=89=E9=A1=B9=E5=8D=A1=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=88=87=E6=8D=A2=E5=92=8C=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/index.html | 5 +++-- frontend/src/main.ts | 33 +++++++++++++++++++++------------ frontend/src/style.css | 7 ++++++- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 0ee690b..07b51c5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -293,8 +293,9 @@ class="dock-tab" type="button" role="tab" - aria-selected="true" + aria-selected="false" aria-controls="file-manager-panel" + aria-expanded="false" tabindex="0" data-i18n-zh="文件管理" data-i18n-en="Files" @@ -314,7 +315,7 @@ -
+