diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9ddbd92 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Copy to .dev.vars for local development. Do not commit real credentials. +ACCESS_TOKEN=replace-with-a-long-random-value +ALLOW_ANONYMOUS=false +CONNECT_TIMEOUT_MS=10000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9cc801 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.wrangler/ +coverage/ +tests/ +tests-edge/ +*.log +.dev.vars +.env* +!.env.example diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index ab79f44..4f8c2e7 100644 --- a/README.md +++ b/README.md @@ -1 +1,492 @@ -# CF-Workers-WebSSH \ No newline at end of file +# CF-Workers-WebSSH + +一个运行在 Cloudflare Workers 上的原生 WebSSH 终端。浏览器通过 HTTPS/WebSocket 连接 Worker,Worker 使用 Cloudflare TCP Sockets 直接连接公网 SSH 服务器,并在边缘运行时内完成 SSH 2.0 握手、主机密钥校验、用户认证和交互式 PTY 会话。 + +项目不依赖额外的 WebSSH 后端、容器或常驻服务器;前端、会话网关、SSH 客户端实现和静态资源均由同一个 Worker 部署提供。 + +> [!IMPORTANT] +> 这是一个 SSH 访问网关。部署者能够控制 Worker 代码和运行时配置,请只使用自己信任的部署。生产环境务必设置 `ACCESS_TOKEN` 或使用等效的 Cloudflare Access 保护,不要直接开放匿名访问。 + +## 功能特性 + +- Cloudflare Workers 原生部署,使用 Durable Objects 隔离每个 SSH 会话。 +- 基于 xterm.js 的响应式终端,支持桌面端和移动端、自动缩放、全屏和会话日志。 +- 支持密码认证,以及 Ed25519、RSA、ECDSA 的未加密 OpenSSH 私钥认证。 +- 首次连接时暂停认证并显示主机 SHA-256 指纹,确认后才会发送 SSH 凭据。 +- 可在浏览器本地保存连接配置和主机指纹,但不保存密码、私钥或网关令牌。 +- 支持 UTF-8、GB18030、Big5 显示编码、初始命令和无凭据分享链接。 +- 提供一次性会话票据、同源检查、HTTPS 强制、安全响应头和公网目标校验。 +- 运行时无 SSH 第三方依赖,SSH 数据包、密钥交换、加密、认证和通道逻辑均使用 TypeScript 与 Web Crypto 实现。 + +## 工作原理 + +```text +浏览器(xterm.js) + │ HTTPS:申请一次性会话票据 + │ WSS:终端输入、输出和控制消息 + ▼ +Cloudflare Worker + │ 同源检查、ACCESS_TOKEN 校验、静态资源 + ▼ +每会话 Durable Object + │ 消耗一次性票据、校验目标地址、运行 SSH 2.0 客户端 + │ Cloudflare TCP Socket + ▼ +公网 SSH 服务器 +``` + +一次连接的主要流程如下: + +1. 浏览器向 `POST /api/session` 提交网关访问令牌。 +2. Worker 创建独立 Durable Object,并签发一个有效期 60 秒、绑定客户端地址且只能使用一次的票据。 +3. 浏览器携带票据连接 `GET /api/ssh` WebSocket;Durable Object 消耗票据。 +4. Worker 解析目标域名,通过 Cloudflare DNS over HTTPS 获取 A/AAAA 记录,拒绝私有、保留或本地地址。 +5. Durable Object 只连接已经通过校验的 IP,避免 DNS 重绑定,然后在该 TCP 连接上执行 SSH 协议。 +6. 主机密钥签名验证成功后,浏览器确认或比对固定指纹;只有通过后才发送用户认证数据。 +7. 认证成功后建立 PTY 和交互式 Shell,浏览器与 SSH 通道之间开始转发终端数据。 + +## 支持范围 + +| 类别 | 当前支持 | +| --- | --- | +| SSH 协议 | SSH 2.0 交互式 Shell、PTY、窗口尺寸同步、Keepalive | +| 用户认证 | Password、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 | +| 加密算法 | AES-128/256-GCM、AES-128/192/256-CTR | +| MAC | HMAC-SHA2-256、HMAC-SHA2-512(AES-GCM 不使用独立 MAC) | +| 压缩 | 仅 `none` | +| 终端编码 | UTF-8、GB18030、Big5(取决于浏览器 `TextDecoder` 支持) | + +当前限制: + +- 只能连接公网 IP 或解析结果全部为公网地址的域名;内网、回环、链路本地、保留地址和 `.internal` 目标会被拒绝。 +- Cloudflare Workers 不允许连接出站 TCP 25 端口,本项目也会提前拒绝该端口。 +- 不支持加密私钥、PEM/PKCS#8 私钥文件;私钥必须是 `BEGIN OPENSSH PRIVATE KEY` 格式,且前端限制为 64 KiB。 +- 不支持 SSH Agent、键盘交互认证、多因素认证、证书认证、SFTP/SCP、端口转发和 ProxyJump。 +- 不支持 SSH 压缩和会话内重新密钥交换(rekey)。服务端主动 rekey 时当前会话会断开,需要重新连接。 +- 每个浏览器页面同时维护一个活动 SSH 会话。Worker 更新或 Durable Object 重启时,活动会话需要重新连接。 + +## 安全说明 + +### 已实现的保护 + +- 默认关闭:未配置 `ACCESS_TOKEN` 且 `ALLOW_ANONYMOUS` 不为 `true` 时,会话接口返回 `503`。 +- `ACCESS_TOKEN` 使用恒定时间比较;一次性票据使用随机密钥和 HMAC-SHA256 签名。 +- 会话票据绑定请求端 IP、60 秒过期并在第一次使用尝试时立即销毁,降低重放风险。 +- API 和 WebSocket 必须来自页面同源 `Origin`;Cloudflare 边缘上的 HTTP API 请求会被拒绝,页面请求会重定向至 HTTPS。 +- 域名解析后逐个检查公网地址,并直接连接已验证 IP,以限制 SSRF 与 DNS 重绑定。 +- SSH 主机密钥会验证交换签名并计算 `SHA256:` 指纹;没有固定指纹时,认证会暂停等待用户确认。 +- 密码、私钥和网关令牌不会写入 Local Storage 或 Durable Object 存储。连接表单在发起授权后立即清空,Worker 在生成认证请求后释放凭据引用。 +- 响应设置 CSP、HSTS、`X-Frame-Options`、`X-Content-Type-Options` 等安全头。 + +### 信任边界 + +浏览器到 Cloudflare 使用 TLS,Cloudflare Worker 到目标服务器使用 SSH 加密。Worker 是实际的 SSH 客户端,因此密码或私钥会在 Worker 会话内存中被处理;这不是浏览器到目标主机的端到端 SSH。请自行部署、保护 Cloudflare 账户,并为 WebSSH 使用权限最小化的独立账号或密钥。 + +第一次出现的主机指纹不能仅凭页面显示就判定可信。应通过服务器控制台、可信管理员或已有的安全通道核对。例如在 SSH 服务器上查看 Ed25519 主机密钥指纹: + +```bash +sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub -E sha256 +``` + +## 部署教程:Wrangler CLI(推荐) + +### 1. 准备条件 + +部署前需要: + +- 一个 Cloudflare 账户,并已启用 Workers 与 Durable Objects。 +- Node.js `22.12.0` 或更高版本,以及 npm。 +- 一台具有公网 IP 或公网 DNS 记录、可从 Cloudflare 网络访问的 SSH 服务器。 +- 目标 SSH 服务至少支持上表中的一组密钥交换、主机密钥和加密算法。 + +检查本机版本: + +```bash +node --version +npm --version +``` + +### 2. 获取代码并安装依赖 + +```bash +git clone https://github.com/cmliu/CF-Workers-WebSSH.git +cd CF-Workers-WebSSH +npm ci +``` + +如果部署自己的 Fork,请将 clone 地址替换为你的仓库地址。 + +### 3. 登录 Cloudflare + +```bash +npx wrangler login +npx wrangler whoami +``` + +`wrangler login` 会打开浏览器完成 OAuth 授权。无浏览器的 CI 环境应使用权限最小化的 Cloudflare API Token,并通过 CI Secret 提供 `CLOUDFLARE_API_TOKEN`,不要把 Token 写进仓库。 + +### 4. 检查 Worker 配置 + +默认配置位于 [`wrangler.toml`](wrangler.toml): + +```toml +name = "cf-workers-webssh" +main = "src/worker.ts" +compatibility_date = "2026-07-01" +workers_dev = true + +[assets] +directory = "./dist" +binding = "ASSETS" +not_found_handling = "single-page-application" +run_worker_first = true + +[vars] +ALLOW_ANONYMOUS = "false" +CONNECT_TIMEOUT_MS = "10000" +``` + +可以修改 `name`,它决定默认的 `*.workers.dev` 子域名。请保留 `SSH_SESSIONS` Durable Object binding 和 `v1` migration;首次部署会用它创建 `SSHSessionDO` SQLite 类。 + +### 5. 完整检查 + +```bash +npm run check +``` + +该命令依次执行 Worker 与前端类型检查、Node 单元测试、Cloudflare 运行时测试、前端构建和 Wrangler dry-run。任何一步失败都应先修复,不建议跳过后直接部署。 + +也可以单独运行: + +```bash +npm run typecheck +npm test +npm run test:edge +npm run build:web +``` + +### 6. 首次发布 + +```bash +npm run deploy +``` + +`deploy` 会先把前端构建到 `dist/`,再由 Wrangler 上传 Worker、静态资源、Durable Object binding 和 migration。成功后终端会输出类似地址: + +```text +https://cf-workers-webssh..workers.dev +``` + +此时尚未设置 `ACCESS_TOKEN`,因此页面和健康接口可以访问,但默认 `ALLOW_ANONYMOUS = "false"` 会阻止创建 SSH 会话。这是安全的初始状态。 + +### 7. 设置网关访问令牌 + +先生成一个足够长的随机值。下面的命令适用于 Node.js: + +```bash +node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))" +``` + +然后以 Cloudflare Secret 方式保存,按提示粘贴刚生成的值: + +```bash +npx wrangler secret put ACCESS_TOKEN +``` + +Wrangler 会为已部署的 Worker 创建并激活包含该 Secret 的新版本。Secret 不会出现在 `wrangler.toml` 或构建产物中。此后每次在页面连接时,需要在“高级选项 / Advanced options”的“网关访问令牌”中输入同一个值。 + +如需轮换令牌,重新运行 `npx wrangler secret put ACCESS_TOKEN` 即可。删除令牌使用: + +```bash +npx wrangler secret delete ACCESS_TOKEN +``` + +删除后,在默认 `ALLOW_ANONYMOUS = "false"` 配置下网关会安全地停止签发会话票据。 + +### 8. 验证部署 + +先检查健康接口: + +```bash +curl https://cf-workers-webssh..workers.dev/api/health +``` + +预期返回: + +```json +{"status":"ok","runtime":"cloudflare-workers","ssh":true} +``` + +然后在浏览器打开 Worker 地址: + +1. 输入 SSH 公网主机、端口和用户名。 +2. 选择密码或 SSH 密钥认证并输入凭据。 +3. 展开“高级选项”,输入 `ACCESS_TOKEN`。 +4. 点击“连接”。第一次连接时,通过可信渠道核对页面显示的 SHA-256 主机指纹。 +5. 确认后进入交互式 Shell。若勾选记住指纹,后续连接会在当前浏览器中自动固定并比对。 + +### 9. 更新部署 + +```bash +git pull --ff-only +npm ci +npm run check +npm run deploy +``` + +更新前建议阅读代码差异,尤其是 `wrangler.toml` 中的 Durable Object migration。不要删除已经发布过的 migration tag,也不要随意修改已部署 Durable Object 的类名。 + +## 配置项 + +| 名称 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `ACCESS_TOKEN` | Secret | 未设置 | 推荐的网关访问令牌。只要设置了它,每次创建会话都必须提交正确令牌。 | +| `ALLOW_ANONYMOUS` | Variable | `"false"` | 仅在没有 `ACCESS_TOKEN` 时生效;只有精确设置为字符串 `"true"` 才允许匿名创建会话。 | +| `CONNECT_TIMEOUT_MS` | Variable | `"10000"` | 每个已验证目标 IP 的 TCP 建连超时,运行时会限制在 2000 到 30000 ms。 | +| `SSH_SESSIONS` | Durable Object binding | 已配置 | 每个连接独立的会话对象。不要改名,除非同时修改代码与 migration。 | +| `ASSETS` | Workers Assets binding | 已配置 | 将 `dist/` 静态资源交给 Worker 提供。 | + +普通变量可以编辑 `wrangler.toml` 后重新部署。敏感值必须使用 `wrangler secret` 或 Cloudflare Dashboard 的加密 Secret,不要放进 `[vars]`。 + +### 匿名模式(不推荐用于公网) + +如果明确接受任何访问者都能借助你的 Worker 发起公网 SSH TCP 连接,可以删除 `ACCESS_TOKEN` 并修改: + +```toml +[vars] +ALLOW_ANONYMOUS = "true" +CONNECT_TIMEOUT_MS = "10000" +``` + +随后重新运行 `npm run deploy`。公开网关可能被滥用、产生费用或导致 Cloudflare 账户被限制;至少应配合 Cloudflare Access、限流策略和使用监控。 + +## 自定义域名与 Cloudflare Access + +### 绑定自定义域名 + +在 Cloudflare Dashboard 中进入: + +`Workers & Pages` -> 选择该 Worker -> `Settings` -> `Domains & Routes` -> `Add` -> `Custom domain` + +输入同一 Cloudflare 账户下 Zone 中的域名,例如 `ssh.example.com`。Cloudflare 会创建路由并签发 HTTPS 证书,生效后用以下地址检查: + +```bash +curl https://ssh.example.com/api/health +``` + +也可以将路由写入 `wrangler.toml` 后部署: + +```toml +routes = [ + { pattern = "ssh.example.com", custom_domain = true } +] +``` + +确认自定义域名可用后,如果不希望用户绕过自定义域名访问 `workers.dev`,将配置改为: + +```toml +workers_dev = false +``` + +然后再次执行 `npm run deploy`。关闭前先确认自定义域名和 Access 策略工作正常,避免把自己锁在外面。 + +### 使用 Cloudflare Access + +可在 Cloudflare Zero Trust 中为 `ssh.example.com` 创建 Self-hosted Application,并配置只允许指定用户、邮箱域或身份提供商访问。WebSocket 使用同一站点的 Access 会话,适合保护整个 WebSSH 页面和 API。 + +建议采用纵深防御:Cloudflare Access 与 `ACCESS_TOKEN` 同时开启。如果选择只依赖 Access: + +- 确保所有可访问域名都受 Access 保护。 +- 设置 `workers_dev = false`,避免公开的 `workers.dev` 地址绕过自定义域名策略。 +- 删除 `ACCESS_TOKEN` 后才需要设置 `ALLOW_ANONYMOUS = "true"`,否则项目自身仍会拒绝签发会话。 +- 不要仅在前端隐藏令牌输入框;真正的访问控制必须位于 Cloudflare Access 或 Worker 服务端。 + +## 通过 Cloudflare Git 集成部署 + +不想在本机运行 Wrangler 时,可以使用 Cloudflare 的 Git 集成: + +1. 将项目 Fork 到自己的 GitHub 或 GitLab 账户。 +2. 在 Cloudflare Dashboard 中进入 `Workers & Pages`,选择创建 Worker 并导入 Git 仓库。 +3. 项目根目录使用 `/`,Node.js 版本选择 22 或更高。 +4. Build command 设置为 `npm run build:web`。 +5. Deploy command 设置为 `npx wrangler deploy`。 +6. 首次部署完成后,在 Worker 的 `Settings` -> `Variables and Secrets` 中新增加密 Secret:`ACCESS_TOKEN`。 +7. 再次触发部署,并按前文方式检查 `/api/health` 与实际 SSH 连接。 + +Dashboard 的菜单名称可能随 Cloudflare UI 更新而略有变化。关键点是:`wrangler.toml` 必须参与部署、前端必须先生成 `dist/`、`ACCESS_TOKEN` 必须是运行时 Secret 而不是公开构建变量。 + +## 本地开发 + +### 创建本地变量文件 + +复制示例文件为 `.dev.vars`: + +Linux/macOS: + +```bash +cp .env.example .dev.vars +``` + +Windows PowerShell: + +```powershell +Copy-Item .env.example .dev.vars +``` + +编辑 `.dev.vars`: + +```dotenv +ACCESS_TOKEN=replace-with-a-long-random-value +ALLOW_ANONYMOUS=false +CONNECT_TIMEOUT_MS=10000 +``` + +`.dev.vars` 已被 `.gitignore` 忽略。不要提交真实令牌、SSH 密码或私钥。 + +### 启动完整本地环境 + +```bash +npm run dev +``` + +该命令先构建前端,再启动 Wrangler,默认访问 `http://localhost:8787`。本地回环地址允许 HTTP,生产边缘请求仍强制 HTTPS。 + +如需前端热更新,使用两个终端: + +终端 1: + +```bash +npm run build:web +npx wrangler dev +``` + +终端 2: + +```bash +npm run dev:web +``` + +然后访问 `http://localhost:5173`。Vite 会把 `/api` HTTP 和 WebSocket 请求代理到本地 Wrangler 的 `8787` 端口。 + +> [!NOTE] +> 本地 Worker 仍然从你的网络连接目标 SSH 服务器;公网目标限制同样生效。不要使用生产 SSH 凭据测试不受信任的代码分支。 + +### 常用命令 + +| 命令 | 作用 | +| --- | --- | +| `npm run dev` | 构建前端并启动本地 Worker | +| `npm run dev:web` | 启动 Vite 前端开发服务器 | +| `npm run build:web` | 将前端构建到 `dist/` | +| `npm run typecheck` | 检查 Worker 与前端 TypeScript | +| `npm test` | 运行 Node 环境单元测试 | +| `npm run test:edge` | 在 Cloudflare Workers 测试池运行边缘加密与安全测试 | +| `npm run check` | 执行全部检查、构建和部署 dry-run | +| `npm run deploy` | 构建前端并部署到 Cloudflare | + +## 项目结构 + +```text +. +├── frontend/ +│ ├── index.html # WebSSH 页面 +│ └── src/ +│ ├── main.ts # xterm、连接状态、配置与 WebSocket 客户端 +│ └── style.css # 响应式界面样式 +├── src/ +│ ├── backend/ +│ │ ├── durable-object.ts # 会话票据、TCP Socket 与会话生命周期 +│ │ ├── security.ts # Token、票据、同源与公网目标校验 +│ │ └── session.ts # SSH 状态机与浏览器消息桥接 +│ ├── ssh/ # SSH 协议、KEX、密码学、认证与通道实现 +│ ├── http-security.ts # HTTPS 与安全响应头 +│ ├── types.ts # Worker 环境、连接消息和 SSH 类型 +│ └── worker.ts # HTTP/API/Assets 入口 +├── tests/ # Node 单元测试 +├── tests-edge/ # Cloudflare 运行时测试 +├── wrangler.toml # Worker、Assets、Durable Object 与 migration +├── vite.config.ts # 前端构建配置 +└── package.json # 脚本与依赖 +``` + +## API 概览 + +这些接口供同源前端使用,不建议作为稳定的公共 API: + +| 接口 | 方法 | 用途 | +| --- | --- | --- | +| `/api/health` | `GET` | 返回 Worker 与 SSH 功能健康状态 | +| `/api/session` | `POST` | 校验 `ACCESS_TOKEN` 并创建一次性会话票据 | +| `/api/ssh?ticket=...&session=...` | `GET` + WebSocket Upgrade | 进入对应 Durable Object 并建立 SSH 会话 | + +`/api/session` 和 `/api/ssh` 都要求请求 `Origin` 与 Worker URL 同源。票据包含敏感授权能力且只能短时使用,不应记录、复用或转发。 + +## 常见问题 + +### `503 Gateway access is not configured` + +没有配置 `ACCESS_TOKEN`,且 `ALLOW_ANONYMOUS` 不是 `"true"`。生产环境运行 `npx wrangler secret put ACCESS_TOKEN`;本地开发则检查 `.dev.vars`。 + +### `401 Invalid access token` + +页面输入的网关令牌与 Worker Secret 不一致。重新输入,或使用 `npx wrangler secret put ACCESS_TOKEN` 轮换。连接发起后令牌输入框会被清空,这是预期行为。 + +### `403 Invalid request origin` / `Invalid WebSocket origin` + +API 只能由同源页面调用。检查是否通过反向代理改写了 `Origin`、协议或 Host,也不要从其他站点直接调用接口。公网部署必须使用 HTTPS/WSS。 + +### `Private and reserved SSH targets are disabled` + +目标是内网/保留 IP,或者 DNS 同时解析到公网和私网地址。当前项目有意不提供内网穿透。如果需要访问私网主机,应使用受控 VPN、Cloudflare Tunnel 配合专门的访问架构,或在私网内运行其他 SSH 网关,而不是关闭此校验。 + +### TCP 连接超时或被拒绝 + +- 确认主机和端口在公网可达,SSH 服务确实监听该端口。 +- 检查云防火墙、安全组、主机防火墙和入站白名单。 +- Cloudflare Workers TCP 出口不提供可由本项目固定的单一源 IP;只允许家庭或办公出口 IP 的白名单通常会拒绝连接。 +- 端口 25 无法使用。需要更长建连时间时,可将 `CONNECT_TIMEOUT_MS` 调高到最多 `30000`。 + +### 没有共同的 KEX、Host Key、Cipher 或 MAC + +目标 SSH 服务器没有启用本项目支持的算法。优先在服务端启用现代算法,例如 `curve25519-sha256`、Ed25519/ECDSA/RSA-SHA2 主机密钥和 AES-GCM/CTR。不要为了兼容而重新启用 `ssh-rsa` SHA-1、过时 CBC 或弱 KEX;本项目不会协商这些算法。 + +### 私钥无法使用 + +- 必须以 `-----BEGIN OPENSSH PRIVATE KEY-----` 开头,而不是 `BEGIN RSA PRIVATE KEY` 或 `BEGIN PRIVATE KEY`。 +- 私钥不能有口令,且大小不能超过 64 KiB。 +- 建议新建仅供此网关使用、权限最小化的 Ed25519 密钥,不要移除日常主密钥的口令。 + +创建一个无口令的专用 Ed25519 密钥: + +```bash +ssh-keygen -t ed25519 -f webssh_ed25519 -N "" +``` + +将 `webssh_ed25519.pub` 安装到目标账户的 `~/.ssh/authorized_keys`,页面中只使用私钥文件 `webssh_ed25519`。妥善限制目标账户权限并安全保管私钥。 + +### 主机密钥不匹配 + +服务器重装、SSH 主机密钥轮换或 DNS 指向变化都可能导致不匹配,也可能代表中间人攻击。不要直接删除已保存指纹;先通过可信渠道核对新指纹。确认是合法轮换后,再更新高级选项中的指纹或清除该站点保存的主机指纹。 + +### 会话运行一段时间后因 rekey 断开 + +当前实现不支持 SSH rekey。达到服务端的时间或流量阈值后,需要重新连接。只有在了解安全影响并符合组织策略时,才考虑调整服务端 `RekeyLimit`;更推荐保留合理阈值并接受会话重连。 + +### Worker 更新时会话断开 + +这是预期行为。部署新版本或 Durable Object 被重新调度后,现有 SSH 流状态无法恢复,浏览器会收到重连提示。部署前请避免在 WebSSH 中运行无法恢复的重要前台任务,或先进入 `tmux`/`screen`。 + +## 运维建议 + +- 使用 Cloudflare Access 和 `ACCESS_TOKEN` 双重保护,关闭不需要的 `workers.dev` 公网入口。 +- 为 WebSSH 创建独立、低权限 SSH 账户和密钥;敏感运维使用 `sudo` 审计与最小授权。 +- 定期轮换 `ACCESS_TOKEN`,开启 Cloudflare 账户 MFA,并限制部署 API Token 权限。 +- 监控 Worker、Durable Objects 和网络使用量。每个活动终端都会占用 Durable Object 与出站 TCP 连接,费用和限制以 Cloudflare 当前套餐为准。 +- 更新前执行 `npm run check`,保留已经发布的 Durable Object migrations,并在低峰期部署。 +- 不要在 URL、Issue、日志、截图或分享链接中放入密码、私钥、网关令牌或一次性票据。 + +## 许可证 + +本项目采用 [Apache License 2.0](LICENSE)。 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..52cbb41 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,239 @@ + + + + + + + + + Workers WebSSH + + +
+
+
+ + + + Workers WebSSH + + 边缘原生 / EDGE NATIVE +
+ +
+
+ + 离线 / Offline +
+ + + + +
+
+ +
+ + + +
+
+
+ +
+ 无活动会话 / No active session + 选择目标并连接 / Choose a target and connect +
+
+
+
边缘 / EDGE--
+
延迟 / RTT--
+
时长 / UPTIME00:00
+
主机密钥 / HOST KEY--
+
+
+ +
+
+ +
终端 / terminal · 等待 / waiting
+
+ + + +
+
+
+
+
+ +

终端已就绪 / Your terminal is ready

+

从连接面板建立 SSH 连接,开始加密的边缘会话。
Open an SSH connection from the panel to start an encrypted edge session.

+ +
+
+
+ Worker 运行时待命 / Worker runtime standing by + +
+ +
+
+
+ +
+ + +
+ +
+

主机身份 / HOST IDENTITY

+

信任此 SSH 主机? / Trust this SSH host?

+

服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。
The server presented a key that has not been pinned for this target. Verify it through a trusted channel before continuing.

+
+
+
目标 / Target
--
+
密钥类型 / Key type
--
+
SHA-256 指纹 / fingerprint
--
+
+ +
+ + +
+
+
+
+ + + diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..7be9f86 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..94e8bd8 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,1154 @@ +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import './style.css'; + +type AuthMethod = 'password' | 'publickey'; +type ConnectionState = 'idle' | 'connecting' | 'connected' | 'error'; + +interface SavedProfile { + id: string; + name: string; + host: string; + port: number; + username: string; + authMethod: AuthMethod; + initialCommand: string; + termType: string; + encoding: string; + fingerprint: string; + updatedAt: number; +} + +interface ConnectionConfig { + type: 'connect'; + host: string; + port: number; + username: string; + password?: string; + authMethod: AuthMethod; + privateKey?: string; + cols: number; + rows: number; + term: string; + expectedFingerprint?: string; +} + +interface ServerMessage { + type?: string; + event?: string; + message?: string; + fingerprint?: string; + keyType?: string; + trusted?: boolean; + latency?: number; + colo?: string; + ts?: number; + algorithms?: Record; +} + +interface WSSHOptions { + hostname?: string; + host?: string; + port?: string | number; + username?: string; + password?: string; + privatekey?: string; + privateKey?: string; + command?: string; + term?: string; + encoding?: string; + fingerprint?: string; +} + +interface WSSHCompatibilityAPI { + connect: ((options?: WSSHOptions) => Promise) & + ((host: string, port?: string | number, username?: string, password?: string, privateKey?: string) => Promise); + send: (data: string) => void; + resize: () => void; + set_encoding: (encoding: string) => void; + reset_encoding: () => void; + disconnect: () => void; +} + +declare global { + interface Window { + wssh: WSSHCompatibilityAPI; + } +} + +const PROFILE_STORAGE_KEY = 'workers-webssh.profiles.v1'; +const HOST_KEY_STORAGE_KEY = 'workers-webssh.hostkeys.v1'; +const THEME_STORAGE_KEY = 'workers-webssh.theme'; +const MAX_PROFILES = 30; +const MAX_KEY_BYTES = 65_536; +const PING_INTERVAL_MS = 25_000; + +function bilingual(zh: string, en: string): string { + return `${zh} / ${en}`; +} + +const EVENT_LABELS: Record = { + session: bilingual('会话', 'session'), + connect: bilingual('连接', 'connect'), + transport: bilingual('传输', 'transport'), + authorization: bilingual('授权', 'authorization'), + disconnect: bilingual('断开', 'disconnect'), + protocol: bilingual('协议', 'protocol'), + status: bilingual('状态', 'status'), + ready: bilingual('就绪', 'ready'), + error: bilingual('错误', 'error'), + debug: bilingual('调试', 'debug'), + 'host-key': bilingual('主机密钥', 'host key'), +}; + +const SERVER_EVENT_MESSAGES: Record = { + version_exchange: bilingual('正在交换 SSH 协议版本', 'Exchanging SSH protocol versions'), + version_ready: bilingual('版本交换完成,正在协商密钥', 'Version exchange complete; negotiating keys'), + tcp_connecting: bilingual('正在连接 SSH 服务器', 'Connecting to the SSH server'), + authenticating: bilingual('加密传输已建立,正在认证', 'Encrypted transport established; authenticating'), + host_key_confirmation: bilingual('发送凭据前请确认此主机密钥', 'Confirm this host key before credentials are sent'), + auth_success: bilingual('SSH 认证成功,正在打开终端', 'SSH authentication succeeded; opening terminal'), + shell_ready: bilingual('Shell 已就绪', 'Shell is ready'), + ready: bilingual('交互式 Shell 已就绪', 'Interactive shell ready'), + remote_closed: bilingual('SSH 服务器已关闭连接', 'The SSH server closed the connection'), + remote_eof: bilingual('SSH 服务器已结束输出', 'SSH server finished sending output'), + session_ended: bilingual('SSH 会话已结束', 'SSH session ended'), + keepalive_timeout: bilingual('SSH 保活响应超时', 'SSH keepalive timed out'), +}; + +const SERVER_MESSAGE_TRANSLATIONS: Record = { + 'Invalid request origin': '请求来源无效', + 'Expected application/json': '请求必须使用 application/json', + 'Invalid access token': '访问令牌无效', + 'Gateway access is not configured': '网关访问尚未配置', + 'Unable to create a session ticket': '无法创建会话票据', + 'Invalid SSH host': 'SSH 主机无效', + 'Invalid SSH port': 'SSH 端口无效', + 'Invalid SSH username': 'SSH 用户名无效', + 'Invalid authentication method': '身份认证方式无效', + 'Unsupported connection field': '包含不支持的连接字段', + 'Invalid terminal size': '终端尺寸无效', + 'Invalid host key fingerprint': '主机密钥指纹无效', + 'SSH authentication failed': 'SSH 认证失败', + 'Host key was not accepted': '主机密钥未被接受', + 'SSH host key signature verification failed': 'SSH 主机密钥签名验证失败', + 'The server does not support SSH 2.0': '服务器不支持 SSH 2.0', + 'SSH compression is not supported': '不支持 SSH 压缩', + 'Server-initiated SSH rekey is not supported': '不支持由服务器发起的 SSH 重新密钥交换', + 'SSH rekey is not supported by this terminal session': '当前终端会话不支持 SSH 重新密钥交换', + 'The SSH server closed the connection': 'SSH 服务器已关闭连接', + 'SSH session ended': 'SSH 会话已结束', + 'Shell is ready': 'Shell 已就绪', + 'Terminal is not ready': '终端尚未就绪', + 'Terminal input queue limit exceeded': '终端输入队列已超出限制', + 'SSH keepalive timed out': 'SSH 保活响应超时', + 'Session closed': '会话已关闭', + 'SSH session failed': 'SSH 会话失败', +}; + +function bilingualServerMessage(message: string | undefined, eventName?: string, fallback?: string, summary = 'SSH 状态更新'): string { + const english = message?.trim() || fallback || eventName || 'SSH status'; + if (/[\u3400-\u9fff].* \/ /.test(english)) return english; + const eventText = eventName ? SERVER_EVENT_MESSAGES[eventName] : undefined; + if (eventText && (!message || message === eventName || eventText.endsWith(` / ${english}`))) return eventText; + const chinese = SERVER_MESSAGE_TRANSLATIONS[english]; + if (chinese) return bilingual(chinese, english); + return bilingual(summary, english); +} + +function element(id: string): T { + const node = document.getElementById(id); + if (!node) throw new Error(`Missing UI element #${id}`); + return node as T; +} + +const ui = { + panel: element('connection-panel'), + panelToggle: element('panel-toggle'), + panelScrim: element('panel-scrim'), + newProfile: element('new-profile'), + profileList: element('profile-list'), + profileCount: element('profile-count'), + form: element('connection-form'), + profileId: element('profile-id'), + profileName: element('profile-name'), + host: element('host'), + port: element('port'), + username: element('username'), + password: element('password'), + passwordField: element('password-field'), + revealPassword: element('reveal-password'), + keyField: element('key-field'), + privateKey: element('private-key'), + keyFile: element('key-file'), + keyFileName: element('key-file-name'), + initialCommand: element('initial-command'), + termType: element('term-type'), + encoding: element('encoding'), + fingerprint: element('fingerprint'), + accessToken: element('access-token'), + formError: element('form-error'), + connect: element('connect-button'), + saveProfile: element('save-profile'), + shareLink: element('share-link'), + globalStatus: element('global-status'), + globalStatusLabel: element('global-status-label'), + themeToggle: element('theme-toggle'), + sessionTitle: element('session-title'), + sessionSubtitle: element('session-subtitle'), + liveOrb: element('live-orb'), + metricEdge: element('metric-edge'), + metricRtt: element('metric-rtt'), + metricUptime: element('metric-uptime'), + metricHostKey: element('metric-host-key'), + terminalCard: element('terminal-card'), + terminalStage: element('terminal-stage'), + terminalElement: element('terminal'), + terminalEmpty: element('terminal-empty'), + terminalLabel: element('terminal-label'), + emptyConnect: element('empty-connect'), + clearTerminal: element('clear-terminal'), + fullscreenTerminal: element('fullscreen-terminal'), + disconnect: element('disconnect-button'), + eventMessage: element('event-message'), + eventToggle: element('event-toggle'), + eventLog: element('event-log'), + toastRegion: element('toast-region'), + hostKeyDialog: element('host-key-dialog'), + hostKeyTarget: element('host-key-target'), + hostKeyType: element('host-key-type'), + hostKeyFingerprint: element('host-key-fingerprint'), + rememberHostKey: element('remember-host-key'), + rejectHostKey: element('reject-host-key'), + acceptHostKey: element('accept-host-key'), +}; + +let profiles = loadProfiles(); +let hostKeys = loadHostKeys(); +let socket: WebSocket | null = null; +let connectionState: ConnectionState = 'idle'; +let sessionStartedAt = 0; +let uptimeTimer: number | null = null; +let pingTimer: number | null = null; +let lastPingAt = 0; +let pendingHostKey: { fingerprint: string; keyType: string } | null = null; +let currentTargetKey = ''; +let currentInitialCommand = ''; +let initialCommandSent = false; +let decoder = new TextDecoder('utf-8'); +let resizeFrame = 0; +let awaitingHostKeyDecision = false; +let connectGeneration = 0; +let authorizationAbort: AbortController | null = null; +let currentExpectedFingerprint = ''; + +const terminal = new Terminal({ + allowProposedApi: false, + convertEol: false, + cursorBlink: true, + cursorStyle: 'block', + fontFamily: 'Cascadia Code, SFMono-Regular, Consolas, Liberation Mono, monospace', + fontSize: 13, + lineHeight: 1.18, + letterSpacing: 0, + scrollback: 10_000, + tabStopWidth: 8, + theme: terminalTheme(), +}); +const fitAddon = new FitAddon(); +terminal.loadAddon(fitAddon); +terminal.loadAddon(new WebLinksAddon()); +terminal.open(ui.terminalElement); + +function terminalTheme(): Record { + return { + background: '#080d12', + foreground: '#d7e2e6', + cursor: '#69e6b4', + cursorAccent: '#080d12', + selectionBackground: '#294b43', + black: '#121b22', + red: '#ff7b82', + green: '#69e6b4', + yellow: '#f5c76b', + blue: '#70b7ff', + magenta: '#c69cff', + cyan: '#67d8e7', + white: '#d7e2e6', + brightBlack: '#647782', + brightRed: '#ff9a9f', + brightGreen: '#94f2ca', + brightYellow: '#ffe09b', + brightBlue: '#a4d2ff', + brightMagenta: '#ddc1ff', + brightCyan: '#99edf5', + brightWhite: '#f7fbfc', + }; +} + +function isSavedProfile(value: unknown): value is SavedProfile { + if (!value || typeof value !== 'object') return false; + const item = value as Partial; + return ( + typeof item.id === 'string' && + typeof item.name === 'string' && + item.name.length <= 64 && + typeof item.host === 'string' && + item.host.length >= 1 && item.host.length <= 253 && !/[\s/?#]/.test(item.host) && + typeof item.port === 'number' && + Number.isInteger(item.port) && + item.port >= 1 && + item.port <= 65_535 && + typeof item.username === 'string' && + item.username.length >= 1 && item.username.length <= 128 && !/[\r\n\0]/.test(item.username) && + (item.authMethod === 'password' || item.authMethod === 'publickey') && + typeof item.initialCommand === 'string' && + item.initialCommand.length <= 4096 && + typeof item.termType === 'string' && + /^[A-Za-z0-9._+-]{1,64}$/.test(item.termType) && + typeof item.encoding === 'string' && + ['utf-8', 'gb18030', 'big5'].includes(item.encoding) && + typeof item.fingerprint === 'string' && + (item.fingerprint === '' || /^SHA256:[A-Za-z0-9+/]{43}$/.test(item.fingerprint)) && + typeof item.updatedAt === 'number' + ); +} + +function loadProfiles(): SavedProfile[] { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(PROFILE_STORAGE_KEY) ?? '[]'); + return Array.isArray(parsed) ? parsed.filter(isSavedProfile).slice(0, MAX_PROFILES) : []; + } catch { + return []; + } +} + +function loadHostKeys(): Record { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(HOST_KEY_STORAGE_KEY) ?? '{}'); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return Object.fromEntries( + Object.entries(parsed).filter(([key, value]) => key.length <= 512 && typeof value === 'string' && /^SHA256:[A-Za-z0-9+/]{43}$/.test(value)), + ); + } catch { + return {}; + } +} + +function persistProfiles(): void { + try { localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(profiles.slice(0, MAX_PROFILES))); } + catch { toast(bilingual('此浏览器无法保存连接配置。', 'Profiles could not be saved in this browser.'), 'error'); } +} + +function persistHostKeys(): void { + try { localStorage.setItem(HOST_KEY_STORAGE_KEY, JSON.stringify(hostKeys)); } + catch { toast(bilingual('此浏览器无法保存主机指纹。', 'Host fingerprints could not be saved in this browser.'), 'error'); } +} + +function authMethod(): AuthMethod { + const checked = ui.form.querySelector('input[name="authMethod"]:checked'); + return checked?.value === 'publickey' ? 'publickey' : 'password'; +} + +function setAuthMethod(method: AuthMethod): void { + const radio = ui.form.querySelector(`input[name="authMethod"][value="${method}"]`); + if (radio) radio.checked = true; + ui.passwordField.hidden = method !== 'password'; + ui.keyField.hidden = method !== 'publickey'; +} + +function resetPasswordField(): void { + ui.password.value = ''; + ui.password.type = 'password'; + ui.revealPassword.textContent = bilingual('显示', 'Show'); + ui.revealPassword.setAttribute('aria-label', bilingual('显示密码', 'Show password')); +} + +function clearPrivateKeyFields(): void { + ui.privateKey.value = ''; + ui.keyFile.value = ''; + ui.keyFileName.textContent = bilingual('未选择文件', 'No file selected'); +} + +function clearCredentials(): void { + resetPasswordField(); + clearPrivateKeyFields(); + ui.accessToken.value = ''; +} + +function normalizeHost(host: string): string { + const trimmed = host.trim(); + if (trimmed.startsWith('[') && trimmed.endsWith(']')) return trimmed.slice(1, -1); + return trimmed; +} + +function targetKey(host = normalizeHost(ui.host.value), port = Number(ui.port.value), username = ui.username.value.trim()): string { + return `${username}@${host.toLowerCase()}:${port}`; +} + +function readProfileFromForm(): SavedProfile { + const host = normalizeHost(ui.host.value); + const port = Number(ui.port.value); + const username = ui.username.value.trim(); + return { + id: ui.profileId.value || crypto.randomUUID(), + name: ui.profileName.value.trim() || `${username || 'ssh'}@${host || 'host'}`, + host, + port, + username, + authMethod: authMethod(), + initialCommand: ui.initialCommand.value, + termType: ui.termType.value, + encoding: ui.encoding.value, + fingerprint: ui.fingerprint.value.trim(), + updatedAt: Date.now(), + }; +} + +function validateProfileFields(): string | null { + const host = normalizeHost(ui.host.value); + const port = Number(ui.port.value); + const username = ui.username.value.trim(); + if (!host || host.length > 253 || /[\s/?#]/.test(host)) return bilingual('请输入有效的主机名或 IP 地址。', 'Enter a valid hostname or IP address.'); + if (!Number.isInteger(port) || port < 1 || port > 65_535) return bilingual('端口必须介于 1 和 65535 之间。', 'Port must be between 1 and 65535.'); + if (!username || username.length > 128 || /[\r\n\0]/.test(username)) return bilingual('请输入有效的 SSH 用户名。', 'Enter a valid SSH username.'); + const fingerprint = ui.fingerprint.value.trim(); + if (fingerprint && !/^SHA256:[A-Za-z0-9+/]{43}$/.test(fingerprint)) return bilingual('主机指纹必须使用 SHA256:base64 格式。', 'Host fingerprint must use the SHA256:base64 format.'); + return null; +} + +function validateConnection(): string | null { + const profileError = validateProfileFields(); + if (profileError) return profileError; + if (authMethod() === 'publickey') { + const key = ui.privateKey.value.trim(); + if (!key) return bilingual('请粘贴或选择未加密的 OpenSSH 私钥。', 'Paste or choose an unencrypted OpenSSH private key.'); + if (new TextEncoder().encode(key).length > MAX_KEY_BYTES) return bilingual('私钥大于 64 KiB。', 'The private key is larger than 64 KiB.'); + if (!key.includes('BEGIN OPENSSH PRIVATE KEY')) return bilingual('仅支持未加密的 OpenSSH 私钥。', 'Only unencrypted OpenSSH private keys are supported.'); + } + return null; +} + +function validateConnectForm(): string | null { + const browserInvalid = [...ui.form.elements].find((control): control is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement => + control instanceof HTMLInputElement || control instanceof HTMLSelectElement || control instanceof HTMLTextAreaElement + ? !control.validity.valid + : false); + if (browserInvalid) return bilingual('请检查必填项和字段格式。', 'Check the required fields and their formats.'); + return validateConnection(); +} + +function applyProfile(profile: SavedProfile): void { + clearCredentials(); + ui.profileId.value = profile.id; + ui.profileName.value = profile.name; + ui.host.value = profile.host; + ui.port.value = String(profile.port); + ui.username.value = profile.username; + ui.initialCommand.value = profile.initialCommand; + ui.termType.value = profile.termType; + ui.encoding.value = profile.encoding; + ui.fingerprint.value = profile.fingerprint || hostKeys[targetKey(profile.host, profile.port, profile.username)] || ''; + setAuthMethod(profile.authMethod); + renderProfiles(); +} + +function clearForm(): void { + ui.form.reset(); + clearCredentials(); + ui.profileId.value = ''; + ui.port.value = '22'; + ui.termType.value = 'xterm-256color'; + ui.encoding.value = 'utf-8'; + ui.fingerprint.value = ''; + ui.formError.hidden = true; + setAuthMethod('password'); + renderProfiles(); + ui.profileName.focus(); +} + +function saveCurrentProfile(): void { + const error = validateProfileFields(); + if (error) { + showFormError(error); + return; + } + const profile = readProfileFromForm(); + const index = profiles.findIndex((item) => item.id === profile.id); + if (index >= 0) profiles[index] = profile; + else profiles.unshift(profile); + profiles = profiles.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_PROFILES); + ui.profileId.value = profile.id; + persistProfiles(); + renderProfiles(); + toast(bilingual('连接配置已保存,凭据未被存储。', 'Connection profile saved. Credentials were not stored.')); +} + +function deleteProfile(id: string): void { + profiles = profiles.filter((profile) => profile.id !== id); + persistProfiles(); + if (ui.profileId.value === id) clearForm(); + else renderProfiles(); +} + +function renderProfiles(): void { + ui.profileList.replaceChildren(); + ui.profileCount.textContent = String(profiles.length); + if (profiles.length === 0) { + const empty = document.createElement('p'); + empty.className = 'empty-list'; + empty.textContent = bilingual('暂无已保存目标', 'No saved targets yet.'); + ui.profileList.append(empty); + return; + } + + for (const profile of profiles) { + const card = document.createElement('div'); + card.className = `profile-card${profile.id === ui.profileId.value ? ' active' : ''}`; + + const main = document.createElement('button'); + main.className = 'profile-main'; + main.type = 'button'; + main.dataset.profileId = profile.id; + const avatar = document.createElement('span'); + avatar.className = 'profile-avatar'; + avatar.textContent = profile.name.slice(0, 2).toUpperCase(); + const copy = document.createElement('span'); + copy.className = 'profile-copy'; + const title = document.createElement('strong'); + title.textContent = profile.name; + const target = document.createElement('span'); + target.textContent = `${profile.username}@${profile.host}:${profile.port}`; + copy.append(title, target); + main.append(avatar, copy); + + const remove = document.createElement('button'); + remove.className = 'profile-delete'; + remove.type = 'button'; + remove.dataset.deleteProfile = profile.id; + remove.setAttribute('aria-label', bilingual(`删除 ${profile.name}`, `Delete ${profile.name}`)); + remove.textContent = '\u00d7'; + card.append(main, remove); + ui.profileList.append(card); + } +} + +function showFormError(message: string): void { + ui.formError.textContent = message; + ui.formError.hidden = false; +} + +function toast(message: string, kind: 'info' | 'error' = 'info'): void { + const item = document.createElement('div'); + item.className = `toast${kind === 'error' ? ' error' : ''}`; + item.textContent = message; + ui.toastRegion.append(item); + window.setTimeout(() => item.remove(), 4_500); +} + +function setState(state: ConnectionState, label?: string): void { + connectionState = state; + ui.globalStatus.dataset.state = state; + ui.globalStatusLabel.textContent = label ?? ({ + idle: bilingual('离线', 'Offline'), + connecting: bilingual('连接中', 'Connecting'), + connected: bilingual('在线', 'Online'), + error: bilingual('错误', 'Error'), + } satisfies Record)[state]; + ui.liveOrb.className = `live-orb ${state}`; + ui.connect.disabled = state === 'connecting' || state === 'connected'; + ui.connect.querySelector('span:last-child')!.textContent = state === 'connecting' + ? bilingual('连接中...', 'Connecting...') + : state === 'connected' ? bilingual('已连接', 'Connected') : bilingual('连接', 'Connect'); + ui.disconnect.disabled = state !== 'connecting' && state !== 'connected'; +} + +function event(message: string, category = 'session', error = false): void { + ui.eventMessage.textContent = message; + const line = document.createElement('div'); + line.className = `event-line${error ? ' error' : ''}`; + const time = document.createElement('time'); + time.textContent = new Date().toLocaleTimeString([], { hour12: false }); + const type = document.createElement('strong'); + type.textContent = EVENT_LABELS[category] ?? bilingual('SSH 事件', category); + const copy = document.createElement('span'); + copy.textContent = message; + line.append(time, type, copy); + ui.eventLog.append(line); + while (ui.eventLog.childElementCount > 100) ui.eventLog.firstElementChild?.remove(); + ui.eventLog.scrollTop = ui.eventLog.scrollHeight; +} + +function fitTerminal(send = true): void { + cancelAnimationFrame(resizeFrame); + resizeFrame = requestAnimationFrame(() => { + try { + fitAddon.fit(); + if (send && socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'resize', cols: terminal.cols, rows: terminal.rows })); + } + } catch { + // The terminal can be temporarily dimensionless during a mobile drawer transition. + } + }); +} + +function openPanel(open: boolean): void { + const mobile = window.matchMedia('(max-width: 760px)').matches; + const visible = mobile && open; + ui.panel.classList.toggle('open', visible); + ui.panel.inert = mobile && !visible; + ui.panel.setAttribute('aria-hidden', String(mobile && !visible)); + ui.panelToggle.setAttribute('aria-expanded', String(visible)); + ui.panelToggle.setAttribute('aria-label', visible + ? bilingual('关闭连接面板', 'Close connection panel') + : bilingual('打开连接面板', 'Open connection panel')); + ui.panelScrim.hidden = !visible; +} + +function updateUptime(): void { + if (!sessionStartedAt) { + ui.metricUptime.textContent = '00:00'; + return; + } + const seconds = Math.max(0, Math.floor((Date.now() - sessionStartedAt) / 1000)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainder = seconds % 60; + ui.metricUptime.textContent = hours > 0 + ? `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}` + : `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`; +} + +function startTimers(): void { + stopTimers(); + sessionStartedAt = Date.now(); + updateUptime(); + uptimeTimer = window.setInterval(updateUptime, 1_000); + pingTimer = window.setInterval(() => { + if (socket?.readyState !== WebSocket.OPEN) return; + lastPingAt = performance.now(); + socket.send(JSON.stringify({ type: 'ping', ts: Date.now() })); + }, PING_INTERVAL_MS); +} + +function stopTimers(): void { + if (uptimeTimer !== null) window.clearInterval(uptimeTimer); + if (pingTimer !== null) window.clearInterval(pingTimer); + uptimeTimer = null; + pingTimer = null; + sessionStartedAt = 0; +} + +function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive shell ready')): void { + if (connectionState === 'connected') return; + setState('connected'); + startTimers(); + ui.sessionSubtitle.textContent = message; + event(message, 'ready'); + if (currentInitialCommand && !initialCommandSent) { + initialCommandSent = true; + window.setTimeout(() => sendTerminalData(`${currentInitialCommand}\r`), 120); + } + terminal.focus(); +} + +function sendHostKeyDecision(accept: boolean): void { + if (!awaitingHostKeyDecision || !pendingHostKey || socket?.readyState !== WebSocket.OPEN) return; + const hostKey = pendingHostKey; + awaitingHostKeyDecision = false; + pendingHostKey = null; + socket.send(JSON.stringify({ + type: 'host_key_decision', + accept, + fingerprint: hostKey.fingerprint, + })); + if (accept && ui.rememberHostKey.checked && currentTargetKey) { + hostKeys[currentTargetKey] = hostKey.fingerprint; + persistHostKeys(); + if (targetKey() === currentTargetKey) ui.fingerprint.value = hostKey.fingerprint; + } + event(accept + ? bilingual('主机密钥已接受,可以继续认证。', 'Host key accepted; authentication may continue.') + : bilingual('主机密钥已拒绝。', 'Host key rejected.'), 'host-key', !accept); +} + +function clearHostKeyPrompt(): void { + awaitingHostKeyDecision = false; + pendingHostKey = null; + if (ui.hostKeyDialog.open) ui.hostKeyDialog.close('reject'); + ui.hostKeyDialog.returnValue = ''; +} + +function handleServerMessage(message: ServerMessage): void { + const type = message.type ?? 'status'; + if (type === 'pong') { + if (lastPingAt > 0) ui.metricRtt.textContent = `${Math.max(1, Math.round(performance.now() - lastPingAt))} ms`; + return; + } + if (type === 'rtt') { + if (typeof message.latency === 'number') ui.metricRtt.textContent = `${Math.round(message.latency)} ms`; + if (message.colo) ui.metricEdge.textContent = message.colo; + return; + } + if (type === 'host_key') { + const fingerprint = message.fingerprint ?? ''; + const keyType = message.keyType ?? ''; + if (!/^SHA256:[A-Za-z0-9+/]{43}$/.test(fingerprint) || !/^[A-Za-z0-9@._+-]{1,128}$/.test(keyType)) { + event(bilingual('收到无效的主机密钥消息。', 'Received an invalid host key message.'), 'protocol', true); + socket?.close(1002, 'Invalid host key message'); + return; + } + pendingHostKey = { fingerprint, keyType }; + ui.metricHostKey.textContent = keyType.replace('ssh-', '').replace('ecdsa-sha2-', ''); + ui.metricHostKey.title = fingerprint; + const expected = currentExpectedFingerprint; + if (expected && expected !== fingerprint) { + event(bilingual('主机密钥不匹配,连接已拒绝。', 'Host key mismatch; connection rejected.'), 'host-key', true); + socket?.close(1008, 'Host key mismatch'); + return; + } + if (message.trusted || expected) { + event(bilingual('已固定的主机密钥匹配。', 'Pinned host key matched.'), 'host-key'); + return; + } + awaitingHostKeyDecision = true; + ui.hostKeyTarget.textContent = ui.sessionTitle.textContent ?? currentTargetKey; + ui.hostKeyType.textContent = keyType; + ui.hostKeyFingerprint.textContent = fingerprint; + ui.rememberHostKey.checked = true; + event(bilingual(`认证已暂停,请确认首次见到的主机密钥 ${fingerprint}`, `Authentication paused for first-seen host key ${fingerprint}`), 'host-key'); + if (!ui.hostKeyDialog.open) { + ui.hostKeyDialog.returnValue = ''; + ui.hostKeyDialog.showModal(); + } + return; + } + if (type === 'ready') { + markReady(bilingualServerMessage(message.message, message.event ?? 'ready', 'Interactive shell ready')); + return; + } + if (type === 'error') { + const text = bilingualServerMessage(message.message, message.event, 'The SSH session failed.', 'SSH 错误'); + clearHostKeyPrompt(); + event(text, message.event ?? 'error', true); + showFormError(text); + toast(text, 'error'); + setState('error'); + if (socket?.readyState === WebSocket.OPEN) socket.close(1011, 'SSH session failed'); + return; + } + if (type === 'debug') { + event(bilingualServerMessage(message.message, message.event, 'Debug event'), 'debug'); + return; + } + if (type === 'status') { + const text = bilingualServerMessage(message.message, message.event, 'SSH handshake in progress'); + event(text, message.event ?? 'status'); + ui.sessionSubtitle.textContent = text; + if (message.event === 'shell_ready' || message.event === 'ready') markReady(text); + } +} + +async function handleSocketData(data: string | ArrayBuffer | Blob): Promise { + if (typeof data === 'string') { + try { + handleServerMessage(JSON.parse(data) as ServerMessage); + } catch { + event(bilingual('已忽略无效的控制帧。', 'Ignored an invalid control frame.'), 'protocol', true); + } + return; + } + const bytes = data instanceof Blob ? new Uint8Array(await data.arrayBuffer()) : new Uint8Array(data); + if (decoder.encoding === 'utf-8') terminal.write(bytes); + else terminal.write(decoder.decode(bytes, { stream: true })); +} + +function sendTerminalData(data: string): void { + if (socket?.readyState !== WebSocket.OPEN || connectionState !== 'connected' || !data) return; + socket.send(JSON.stringify({ type: 'input', data })); +} + +async function issueTicket(accessToken: string, signal: AbortSignal): Promise<{ ticket: string; sessionId: string }> { + const response = await fetch('/api/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ accessToken }), + signal, + }); + let payload: { ticket?: string; sessionId?: string; error?: string } = {}; + try { + payload = await response.json() as { ticket?: string; sessionId?: string; error?: string }; + } catch { + // The HTTP status still gives a useful fallback below. + } + if (!response.ok || !payload.ticket || !payload.sessionId) { + throw new Error(payload.error + ? bilingualServerMessage(payload.error, undefined, undefined, '请求失败') + : bilingual(`会话授权失败(HTTP ${response.status})。`, `Session authorization failed (HTTP ${response.status}).`)); + } + return { ticket: payload.ticket, sessionId: payload.sessionId }; +} + +async function connect(): Promise { + if (connectionState === 'connecting' || connectionState === 'connected') return; + const validationError = validateConnectForm(); + if (validationError) { + showFormError(validationError); + return; + } + if (location.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(location.hostname)) { + showFormError(bilingual('发送 SSH 凭据前必须使用 HTTPS。', 'HTTPS is required before SSH credentials can be sent.')); + return; + } + + ui.formError.hidden = true; + const generation = ++connectGeneration; + const abortController = new AbortController(); + authorizationAbort?.abort(); + authorizationAbort = abortController; + setState('connecting'); + ui.terminalEmpty.hidden = true; + terminal.clear(); + fitTerminal(false); + + const host = normalizeHost(ui.host.value); + const port = Number(ui.port.value); + const username = ui.username.value.trim(); + currentTargetKey = targetKey(host, port, username); + const pinnedKey = ui.fingerprint.value.trim() || hostKeys[currentTargetKey] || ''; + currentExpectedFingerprint = pinnedKey; + if (pinnedKey) ui.fingerprint.value = pinnedKey; + currentInitialCommand = ui.initialCommand.value; + initialCommandSent = false; + pendingHostKey = null; + awaitingHostKeyDecision = false; + decoder = createDecoder(ui.encoding.value); + ui.sessionTitle.textContent = `${username}@${host}:${port}`; + ui.sessionSubtitle.textContent = bilingual('正在授权 Worker 会话...', 'Authorizing Worker session...'); + ui.metricEdge.textContent = '--'; + ui.metricRtt.textContent = '--'; + ui.metricHostKey.textContent = '--'; + ui.terminalLabel.textContent = `终端 / terminal · ${username}@${host}`; + event(bilingual(`正在连接 ${username}@${host}:${port}`, `Starting ${username}@${host}:${port}`), 'connect'); + + try { + const accessToken = ui.accessToken.value; + const password = ui.password.value; + const privateKey = ui.privateKey.value.trim(); + const method = authMethod(); + const term = ui.termType.value; + const ticketRequest = issueTicket(accessToken, abortController.signal); + clearCredentials(); + const { ticket, sessionId } = await ticketRequest; + if (authorizationAbort === abortController) authorizationAbort = null; + if (generation !== connectGeneration) return; + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = new URL('/api/ssh', location.href); + url.protocol = protocol; + url.searchParams.set('ticket', ticket); + url.searchParams.set('session', sessionId); + + const activeSocket = new WebSocket(url); + socket = activeSocket; + activeSocket.binaryType = 'arraybuffer'; + activeSocket.addEventListener('open', () => { + if (socket !== activeSocket || generation !== connectGeneration) { + activeSocket.close(1000, 'Connection attempt superseded'); + return; + } + fitTerminal(false); + const config: ConnectionConfig = { + type: 'connect', + host, + port, + username, + authMethod: method, + cols: terminal.cols, + rows: terminal.rows, + term, + }; + if (method === 'password') config.password = password; + else config.privateKey = privateKey; + if (pinnedKey) config.expectedFingerprint = pinnedKey; + activeSocket.send(JSON.stringify(config)); + ui.sessionSubtitle.textContent = bilingual('正在打开 TCP 连接...', 'Opening TCP connection...'); + event(bilingual('WebSocket 已建立,正在打开 SSH 传输。', 'WebSocket established; opening SSH transport.'), 'transport'); + }, { once: true }); + activeSocket.addEventListener('message', (socketEvent) => { + if (socket === activeSocket) void handleSocketData(socketEvent.data as string | ArrayBuffer | Blob); + }); + activeSocket.addEventListener('error', () => { + if (socket !== activeSocket) return; + clearHostKeyPrompt(); + event(bilingual('WebSocket 传输错误。', 'WebSocket transport error.'), 'transport', true); + setState('error'); + }); + activeSocket.addEventListener('close', (closeEvent) => { + if (socket !== activeSocket) return; + socket = null; + currentExpectedFingerprint = ''; + stopTimers(); + clearHostKeyPrompt(); + const wasActive = connectionState === 'connected'; + const reason = closeEvent.reason + ? bilingualServerMessage(closeEvent.reason) + : closeEvent.code === 1000 + ? bilingual('会话已关闭。', 'Session closed.') + : bilingual(`会话已关闭(${closeEvent.code})。`, `Session closed (${closeEvent.code}).`); + event(reason, 'disconnect', closeEvent.code !== 1000 && closeEvent.code !== 1005); + ui.sessionSubtitle.textContent = reason; + setState(closeEvent.code === 1000 || closeEvent.code === 1005 ? 'idle' : 'error'); + ui.disconnect.disabled = true; + if (wasActive) toast(reason, closeEvent.code === 1000 || closeEvent.code === 1005 ? 'info' : 'error'); + }); + } catch (error) { + if (authorizationAbort === abortController) authorizationAbort = null; + if (generation !== connectGeneration) return; + clearCredentials(); + clearHostKeyPrompt(); + const message = error instanceof DOMException && error.name === 'AbortError' + ? bilingual('连接授权已取消。', 'Connection authorization was cancelled.') + : error instanceof Error + ? bilingualServerMessage(error.message, undefined, undefined, '连接失败') + : bilingualServerMessage(String(error), undefined, undefined, '连接失败'); + showFormError(message); + event(message, 'authorization', true); + toast(message, 'error'); + setState('error'); + } +} + +function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected by user')): void { + connectGeneration++; + authorizationAbort?.abort(); + authorizationAbort = null; + const activeSocket = socket; + socket = null; + stopTimers(); + clearHostKeyPrompt(); + currentExpectedFingerprint = ''; + if (activeSocket && activeSocket.readyState < WebSocket.CLOSING) activeSocket.close(1000, 'Disconnected by user'); + ui.sessionSubtitle.textContent = reason; + event(reason, 'disconnect'); + setState('idle'); +} + +function createDecoder(encoding: string): TextDecoder { + try { + return new TextDecoder(encoding, { fatal: false }); + } catch { + ui.encoding.value = 'utf-8'; + toast(bilingual(`此浏览器不支持 ${encoding} 编码,将使用 UTF-8。`, `Encoding ${encoding} is not supported by this browser; using UTF-8.`), 'error'); + return new TextDecoder('utf-8'); + } +} + +function copySafeLink(): void { + const error = validateProfileFields(); + if (error) { + showFormError(error); + return; + } + const url = new URL(location.origin + location.pathname); + url.searchParams.set('hostname', normalizeHost(ui.host.value)); + url.searchParams.set('port', ui.port.value || '22'); + url.searchParams.set('username', ui.username.value.trim()); + url.searchParams.set('term', ui.termType.value); + if (ui.initialCommand.value) url.searchParams.set('command', ui.initialCommand.value); + if (ui.encoding.value !== 'utf-8') url.searchParams.set('encoding', ui.encoding.value); + void navigator.clipboard.writeText(url.toString()).then( + () => toast(bilingual('安全连接链接已复制(不含凭据)。', 'Safe connection link copied (credentials excluded).')), + () => toast(bilingual('无法访问剪贴板。', 'Could not access the clipboard.'), 'error'), + ); +} + +function applyURLParameters(): boolean { + const query = new URLSearchParams(location.search); + const fragment = new URLSearchParams(location.hash.replace(/^#/, '')); + const value = (key: string): string | null => query.get(key) ?? fragment.get(key); + const host = value('hostname') ?? value('host'); + if (host) ui.host.value = host; + if (value('port')) ui.port.value = value('port')!; + if (value('username')) ui.username.value = value('username')!; + if (value('command')) ui.initialCommand.value = value('command')!; + if (value('term')) ui.termType.value = value('term')!; + if (value('encoding')) ui.encoding.value = value('encoding')!; + if (value('fingerprint')) ui.fingerprint.value = value('fingerprint')!; + if (value('title')) document.title = value('title')!; + + const legacyPassword = value('password'); + if (legacyPassword) { + try { + const bytes = Uint8Array.from(atob(legacyPassword), (character) => character.charCodeAt(0)); + ui.password.value = new TextDecoder().decode(bytes); + toast(bilingual('已载入旧版密码 URL,使用后请从浏览器历史记录中删除。', 'A legacy password URL was loaded. Remove it from browser history after use.'), 'error'); + } catch { + toast(bilingual('密码 URL 参数不是有效的 Base64。', 'The password URL parameter is not valid Base64.'), 'error'); + } + } + return Boolean(host && value('username') && (value('autoconnect') === '1' || legacyPassword)); +} + +function applyWSSHOptions(options: WSSHOptions): void { + clearCredentials(); + ui.host.value = options.host ?? options.hostname ?? ui.host.value; + if (options.port !== undefined) ui.port.value = String(options.port); + if (options.username !== undefined) ui.username.value = options.username; + if (options.password !== undefined) { + setAuthMethod('password'); + ui.password.value = options.password; + } + const key = options.privateKey ?? options.privatekey; + if (key !== undefined) { + setAuthMethod('publickey'); + ui.privateKey.value = key; + } + if (options.command !== undefined) ui.initialCommand.value = options.command; + if (options.term !== undefined) ui.termType.value = options.term; + if (options.encoding !== undefined) ui.encoding.value = options.encoding; + if (options.fingerprint !== undefined) ui.fingerprint.value = options.fingerprint; +} + +function initializeCompatibilityAPI(): void { + const compatibilityConnect = async ( + optionsOrHost: WSSHOptions | string = {}, + port?: string | number, + username?: string, + password?: string, + privateKey?: string, + ): Promise => { + if (connectionState === 'connecting' || connectionState === 'connected') { + throw new Error(bilingual('已有活动的 SSH 连接', 'An SSH connection is already active')); + } + const options: WSSHOptions = typeof optionsOrHost === 'string' + ? { host: optionsOrHost, port, username, password, privateKey } + : optionsOrHost; + applyWSSHOptions(options); + await connect(); + }; + window.wssh = { + connect: compatibilityConnect as WSSHCompatibilityAPI['connect'], + send: sendTerminalData, + resize: () => fitTerminal(true), + set_encoding: (encoding: string) => { + ui.encoding.value = encoding; + decoder = createDecoder(encoding); + }, + reset_encoding: () => { + ui.encoding.value = 'utf-8'; + decoder = new TextDecoder('utf-8'); + }, + disconnect, + }; +} + +for (const radio of ui.form.querySelectorAll('input[name="authMethod"]')) { + radio.addEventListener('change', () => setAuthMethod(authMethod())); +} +ui.form.addEventListener('submit', (formEvent) => { + formEvent.preventDefault(); + void connect(); +}); +ui.newProfile.addEventListener('click', clearForm); +ui.saveProfile.addEventListener('click', saveCurrentProfile); +ui.shareLink.addEventListener('click', copySafeLink); +ui.revealPassword.addEventListener('click', () => { + const reveal = ui.password.type === 'password'; + ui.password.type = reveal ? 'text' : 'password'; + ui.revealPassword.textContent = reveal ? bilingual('隐藏', 'Hide') : bilingual('显示', 'Show'); + ui.revealPassword.setAttribute('aria-label', reveal + ? bilingual('隐藏密码', 'Hide password') + : bilingual('显示密码', 'Show password')); +}); +ui.keyFile.addEventListener('change', async () => { + const file = ui.keyFile.files?.[0]; + if (!file) return; + ui.keyFileName.textContent = file.name; + if (file.size > MAX_KEY_BYTES) { + showFormError(bilingual('所选私钥大于 64 KiB。', 'The selected private key is larger than 64 KiB.')); + clearPrivateKeyFields(); + return; + } + try { + ui.privateKey.value = await file.text(); + } catch { + clearPrivateKeyFields(); + showFormError(bilingual('无法读取所选私钥。', 'The selected private key could not be read.')); + } +}); +ui.profileList.addEventListener('click', (clickEvent) => { + const target = clickEvent.target as HTMLElement; + const deleteButton = target.closest('[data-delete-profile]'); + if (deleteButton?.dataset.deleteProfile) { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + deleteProfile(deleteButton.dataset.deleteProfile); + return; + } + const card = target.closest('[data-profile-id]'); + const profile = profiles.find((item) => item.id === card?.dataset.profileId); + if (profile) applyProfile(profile); +}); +ui.panelToggle.addEventListener('click', () => { + const opening = !ui.panel.classList.contains('open'); + openPanel(opening); + if (opening) requestAnimationFrame(() => ui.host.focus()); +}); +ui.panelScrim.addEventListener('click', () => { + openPanel(false); + ui.panelToggle.focus(); +}); +ui.emptyConnect.addEventListener('click', () => { + if (window.matchMedia('(max-width: 760px)').matches) openPanel(true); + ui.host.focus(); +}); +ui.disconnect.addEventListener('click', () => disconnect()); +ui.clearTerminal.addEventListener('click', () => terminal.clear()); +ui.fullscreenTerminal.addEventListener('click', async () => { + if (document.fullscreenElement === ui.terminalCard) await document.exitFullscreen(); + 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.themeToggle.addEventListener('click', () => { + const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light'; + document.documentElement.dataset.theme = next; + try { localStorage.setItem(THEME_STORAGE_KEY, next); } catch { /* Theme still applies for this page. */ } +}); +ui.hostKeyDialog.addEventListener('cancel', (cancelEvent) => { + cancelEvent.preventDefault(); + ui.hostKeyDialog.close('reject'); +}); +ui.hostKeyDialog.addEventListener('close', () => { + sendHostKeyDecision(ui.hostKeyDialog.returnValue === 'accept'); +}); +terminal.onData(sendTerminalData); +new ResizeObserver(() => fitTerminal(true)).observe(ui.terminalStage); +window.addEventListener('beforeunload', () => socket?.close(1000, 'Page closed')); +document.addEventListener('keydown', (keyEvent) => { + if (keyEvent.key === 'Escape' && ui.panel.classList.contains('open') && !ui.hostKeyDialog.open) { + openPanel(false); + ui.panelToggle.focus(); + } +}); +window.matchMedia('(max-width: 760px)').addEventListener('change', () => openPanel(false)); + +let storedTheme: string | null = null; +try { storedTheme = localStorage.getItem(THEME_STORAGE_KEY); } catch { /* Storage can be disabled. */ } +if (storedTheme === 'light' || storedTheme === 'dark') document.documentElement.dataset.theme = storedTheme; +renderProfiles(); +openPanel(false); +setAuthMethod('password'); +setState('idle'); +initializeCompatibilityAPI(); +const shouldAutoConnect = applyURLParameters(); +requestAnimationFrame(() => { + fitTerminal(false); + if (shouldAutoConnect) void connect(); +}); diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..92fbf13 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,1414 @@ +@import '@xterm/xterm/css/xterm.css'; + +:root { + color-scheme: dark; + --bg: #080b10; + --panel: #0d1219; + --surface: #111820; + --surface-2: #17212b; + --surface-3: #1c2935; + --terminal: #080d12; + --border: #253341; + --border-soft: #19242e; + --text: #e9f0f3; + --muted: #8496a3; + --muted-2: #5e707d; + --accent: #69e6b4; + --accent-strong: #36c991; + --accent-dim: rgba(105, 230, 180, 0.12); + --blue: #70b7ff; + --warning: #f5c76b; + --danger: #ff7b82; + --danger-dim: rgba(255, 123, 130, 0.12); + --shadow: 0 24px 60px rgba(0, 0, 0, 0.32); + --topbar-height: calc(62px + env(safe-area-inset-top)); + --mono: "Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +[data-theme='light'] { + color-scheme: light; + --bg: #edf2f3; + --panel: #f8fbfb; + --surface: #ffffff; + --surface-2: #edf4f3; + --surface-3: #e4eeec; + --terminal: #0b1217; + --border: #cbd9d8; + --border-soft: #dce7e5; + --text: #172329; + --muted: #63767c; + --muted-2: #87989c; + --accent: #12825f; + --accent-strong: #0c6d4e; + --accent-dim: rgba(18, 130, 95, 0.1); + --blue: #246da8; + --warning: #a86c0c; + --danger: #c63d4b; + --danger-dim: rgba(198, 61, 75, 0.1); + --shadow: 0 20px 50px rgba(38, 64, 67, 0.13); +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + width: 100%; + min-width: 320px; + height: 100%; + margin: 0; +} + +body { + overflow: hidden; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +button, +input, +textarea, +select { + color: inherit; + font: inherit; +} + +button, +a, +input, +textarea, +select, +summary { + -webkit-tap-highlight-color: transparent; +} + +button, +summary { + cursor: pointer; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible, +summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.topbar { + position: relative; + z-index: 30; + display: flex; + height: var(--topbar-height); + align-items: center; + justify-content: space-between; + padding: env(safe-area-inset-top) max(20px, env(safe-area-inset-right)) 0 max(20px, env(safe-area-inset-left)); + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel) 94%, transparent); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.02); +} + +.brand-group, +.topbar-actions, +.brand, +.session-target, +.form-actions, +.section-title-row, +.panel-heading, +.terminal-actions, +.event-bar, +.file-picker, +.profile-main, +.profile-meta, +.status-pill { + display: flex; + align-items: center; +} + +.brand-group, +.topbar-actions { + gap: 12px; +} + +.brand { + gap: 10px; + color: var(--text); + font-size: 15px; + letter-spacing: -0.01em; + text-decoration: none; +} + +.brand-mark { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--accent) 54%, var(--border)); + border-radius: 9px; + background: var(--accent-dim); + color: var(--accent); + font-family: var(--mono); + font-size: 12px; + font-weight: 700; +} + +.runtime-badge, +.count-badge { + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + font-family: var(--mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; +} + +.runtime-badge { + padding: 5px 8px; + letter-spacing: 0.06em; +} + +.icon-button { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 auto; + place-items: center; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--muted); + text-decoration: none; + transition: background 150ms ease, color 150ms ease, border-color 150ms ease; +} + +.icon-button:hover { + background: var(--surface-2); + color: var(--text); +} + +.icon-button.bordered { + border: 1px solid var(--border); +} + +.icon-button svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.icon-button[href] svg { + fill: currentColor; + stroke: none; +} + +.status-pill { + gap: 7px; + min-width: 92px; + justify-content: center; + padding: 7px 11px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--muted); + font-family: var(--mono); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; +} + +.status-dot, +.live-orb { + display: block; + width: 7px; + height: 7px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--muted-2); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--muted-2) 14%, transparent); +} + +.status-pill[data-state='connecting'] .status-dot, +.live-orb.connecting { + background: var(--warning); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--warning) 14%, transparent); + animation: pulse 1.25s ease-in-out infinite; +} + +.status-pill[data-state='connected'] { + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); + color: var(--accent); +} + +.status-pill[data-state='connected'] .status-dot, +.live-orb.connected { + background: var(--accent); + box-shadow: 0 0 0 3px var(--accent-dim), 0 0 12px color-mix(in srgb, var(--accent) 50%, transparent); +} + +.status-pill[data-state='error'] { + color: var(--danger); +} + +.status-pill[data-state='error'] .status-dot, +.live-orb.error { + background: var(--danger); + box-shadow: 0 0 0 3px var(--danger-dim); +} + +.app-shell { + display: grid; + height: calc(100% - var(--topbar-height)); + grid-template-columns: minmax(300px, 340px) minmax(0, 1fr); +} + +.connection-panel { + position: relative; + z-index: 20; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 24px 20px max(24px, env(safe-area-inset-bottom)); + border-right: 1px solid var(--border); + background: var(--panel); + scrollbar-color: var(--surface-3) transparent; + scrollbar-width: thin; +} + +.panel-heading, +.section-title-row { + justify-content: space-between; +} + +.panel-heading { + margin-bottom: 24px; +} + +.eyebrow { + margin: 0 0 5px; + color: var(--accent); + font-family: var(--mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.17em; +} + +.panel-heading h1 { + margin: 0; + font-size: 20px; + letter-spacing: -0.035em; +} + +.panel-heading h1 small { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 10px; + font-weight: 550; + letter-spacing: 0; +} + +.small-button, +.toolbar-button, +.secondary-button, +.primary-button { + border-radius: 8px; + font-weight: 700; + transition: transform 150ms ease, background 150ms ease, border-color 150ms ease, color 150ms ease; +} + +.small-button, +.toolbar-button { + padding: 7px 10px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--muted); + font-size: 11px; +} + +.small-button:hover, +.toolbar-button:hover, +.secondary-button:hover { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + color: var(--text); +} + +.profiles-section { + margin-bottom: 24px; +} + +.section-title-row { + margin-bottom: 10px; +} + +.section-title-row h2, +.auth-fieldset legend { + color: var(--muted); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.section-title-row h2 { + margin: 0; +} + +.count-badge { + min-width: 23px; + padding: 3px 6px; + text-align: center; +} + +.profile-list { + display: grid; + gap: 7px; + max-height: 190px; + overflow-y: auto; +} + +.empty-list { + margin: 0; + padding: 14px; + border: 1px dashed var(--border); + border-radius: 9px; + color: var(--muted-2); + font-size: 12px; + text-align: center; +} + +.profile-card { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px; + border: 1px solid var(--border-soft); + border-radius: 9px; + background: var(--surface); + color: var(--text); + text-align: left; +} + +.profile-main:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.profile-card:hover, +.profile-card.active { + border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); + background: var(--accent-dim); +} + +.profile-main { + flex: 1; + min-width: 0; + gap: 9px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + text-align: left; +} + +.profile-avatar { + display: grid; + width: 30px; + height: 30px; + flex: 0 0 auto; + place-items: center; + border-radius: 7px; + background: var(--surface-3); + color: var(--accent); + font-family: var(--mono); + font-size: 11px; + font-weight: 700; +} + +.profile-copy { + min-width: 0; +} + +.profile-copy strong, +.profile-copy span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-copy strong { + margin-bottom: 3px; + font-size: 12px; +} + +.profile-copy span, +.profile-meta { + color: var(--muted); + font-family: var(--mono); + font-size: 9px; +} + +.profile-delete { + width: 28px; + height: 28px; + flex: 0 0 auto; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted-2); +} + +.profile-delete:hover { + background: var(--danger-dim); + color: var(--danger); +} + +.connection-form { + display: grid; + gap: 13px; +} + +.field-grid { + display: grid; + gap: 10px; +} + +.field-grid.host-grid { + grid-template-columns: minmax(0, 1fr) 78px; +} + +.field-grid.two { + grid-template-columns: 1fr 1fr; +} + +.field { + display: grid; + min-width: 0; + gap: 6px; +} + +.field > span:first-child, +.field > label:first-child, +.auth-fieldset legend { + color: var(--muted); + font-size: 10px; + font-weight: 750; + letter-spacing: 0.045em; +} + +.field small { + color: var(--muted-2); + font-size: 9px; + font-weight: 500; +} + +.field > span:first-child small { + margin-left: 4px; +} + +.field input, +.field textarea, +.field select { + width: 100%; + min-width: 0; + border: 1px solid var(--border); + border-radius: 8px; + outline: 0; + background: var(--surface); + color: var(--text); + font-family: var(--mono); + font-size: 12px; + transition: background 150ms ease, border-color 150ms ease, box-shadow 150ms ease; +} + +.field input, +.field select { + height: 38px; + padding: 0 10px; +} + +.field textarea { + min-height: 82px; + padding: 9px 10px; + resize: vertical; + line-height: 1.5; +} + +.field input:hover, +.field textarea:hover, +.field select:hover { + border-color: color-mix(in srgb, var(--muted) 54%, var(--border)); +} + +.field input:focus, +.field textarea:focus, +.field select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-dim); + background: color-mix(in srgb, var(--surface) 86%, var(--accent-dim)); +} + +.field input::placeholder, +.field textarea::placeholder { + color: var(--muted-2); +} + +.auth-fieldset { + display: grid; + min-width: 0; + gap: 11px; + margin: 0; + padding: 0; + border: 0; +} + +.auth-fieldset legend { + margin-bottom: 7px; + padding: 0; +} + +.segmented-control { + display: grid; + grid-template-columns: 1fr 1fr; + padding: 3px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--bg); +} + +.segmented-control input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.segmented-control span { + display: block; + padding: 7px; + border-radius: 6px; + color: var(--muted); + font-size: 10px; + font-weight: 700; + text-align: center; +} + +.segmented-control input:checked + span { + background: var(--surface-2); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + color: var(--accent); +} + +.segmented-control input:focus-visible + span { + outline: 2px solid var(--accent); +} + +.input-action-wrap { + position: relative; + display: block; +} + +.input-action-wrap input { + padding-right: 82px; +} + +.input-action { + position: absolute; + top: 5px; + right: 5px; + bottom: 5px; + border: 0; + border-radius: 5px; + background: var(--surface-2); + color: var(--muted); + font-size: 8px; + font-weight: 800; + text-transform: uppercase; +} + +.key-field { + display: grid; + gap: 9px; +} + +.file-picker { + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border: 1px dashed var(--border); + border-radius: 8px; + color: var(--accent); + font-size: 10px; + font-weight: 700; +} + +.file-picker input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.file-picker small { + overflow: hidden; + color: var(--muted-2); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.advanced-options { + border-top: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border-soft); +} + +.advanced-options summary { + padding: 10px 1px; + color: var(--muted); + font-size: 10px; + font-weight: 750; + letter-spacing: 0.05em; + list-style: none; +} + +.advanced-options summary::-webkit-details-marker { + display: none; +} + +.advanced-options summary::after { + content: '+'; + float: right; + color: var(--accent); + font-family: var(--mono); +} + +.advanced-options[open] summary::after { + content: '-'; +} + +.advanced-body { + display: grid; + gap: 11px; + padding: 3px 0 13px; +} + +.form-error { + padding: 9px 10px; + border: 1px solid color-mix(in srgb, var(--danger) 32%, var(--border)); + border-radius: 8px; + background: var(--danger-dim); + color: var(--danger); + font-size: 11px; + line-height: 1.45; +} + +.form-actions { + gap: 8px; +} + +.primary-button, +.secondary-button { + height: 40px; + padding: 0 14px; + border: 1px solid transparent; + font-size: 10px; +} + +.primary-button { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + gap: 8px; + background: var(--accent); + color: #06130e; +} + +[data-theme='light'] .primary-button { + color: #fff; +} + +.primary-button:hover:not(:disabled) { + background: var(--accent-strong); + transform: translateY(-1px); +} + +.button-icon { + font-family: var(--mono); + font-size: 10px; +} + +.secondary-button { + background: var(--surface); + border-color: var(--border); + color: var(--muted); +} + +.privacy-note { + margin: 16px 2px 0; + color: var(--muted-2); + font-size: 9px; + line-height: 1.5; +} + +.workspace { + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + gap: 14px; + overflow: hidden; + padding: 18px; + background: + radial-gradient(circle at 82% 8%, color-mix(in srgb, var(--accent) 5%, transparent), transparent 28%), + var(--bg); +} + +.session-strip { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 11px 14px; + border: 1px solid var(--border); + border-radius: 11px; + background: var(--surface); +} + +.session-target { + min-width: 0; + gap: 11px; +} + +.session-target > div { + min-width: 0; +} + +.session-target strong, +.session-target span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-target strong { + margin-bottom: 3px; + font-size: 12px; +} + +.session-target span { + color: var(--muted); + font-family: var(--mono); + font-size: 9px; +} + +.session-metrics { + display: flex; + align-items: center; + gap: 4px; +} + +.session-metrics > div { + min-width: 82px; + padding: 0 10px; + border-left: 1px solid var(--border-soft); +} + +.session-metrics span, +.session-metrics strong { + display: block; + font-family: var(--mono); + text-align: right; +} + +.session-metrics span { + margin-bottom: 3px; + color: var(--muted-2); + font-size: 7px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.session-metrics strong { + overflow: hidden; + color: var(--muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.terminal-card { + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: 42px minmax(0, 1fr) 34px auto; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--terminal); + box-shadow: var(--shadow); +} + +.terminal-toolbar { + display: grid; + grid-template-columns: 74px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 0 11px; + border-bottom: 1px solid #1c2932; + background: #101820; + color: #8799a5; +} + +.window-controls { + display: flex; + gap: 6px; +} + +.window-controls i { + width: 9px; + height: 9px; + border-radius: 50%; + background: #ff6f75; +} + +.window-controls i:nth-child(2) { + background: #f6c560; +} + +.window-controls i:nth-child(3) { + background: #5fd49d; +} + +.terminal-label { + overflow: hidden; + font-family: var(--mono); + font-size: 9px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.terminal-actions { + gap: 5px; +} + +.terminal-actions .toolbar-button { + padding: 5px 7px; + border-color: #263642; + background: #151f28; + color: #8497a3; + font-size: 8px; +} + +.toolbar-button.danger:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--danger) 48%, #263642); + color: var(--danger); +} + +.terminal-stage { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + padding: 10px 8px 7px 12px; + background: + linear-gradient(rgba(255, 255, 255, 0.012) 50%, transparent 50%) 0 0 / 100% 4px, + var(--terminal); +} + +#terminal { + width: 100%; + height: 100%; +} + +.xterm, +.xterm-viewport, +.xterm-screen { + height: 100%; +} + +.xterm-viewport { + scrollbar-color: #31424e transparent; + scrollbar-width: thin; +} + +.terminal-empty { + position: absolute; + inset: 0; + display: grid; + place-content: center; + justify-items: center; + padding: 24px; + background: var(--terminal); + color: #e9f0f3; + text-align: center; +} + +.terminal-empty[hidden] { + display: none; +} + +.empty-glyph { + display: grid; + width: 54px; + height: 54px; + margin-bottom: 14px; + place-items: center; + border: 1px solid #294137; + border-radius: 13px; + background: rgba(105, 230, 180, 0.08); + color: #69e6b4; + font-family: var(--mono); + font-size: 16px; + font-weight: 700; +} + +.terminal-empty h2 { + margin: 0 0 8px; + font-size: 17px; +} + +.terminal-empty p { + max-width: 430px; + margin: 0 0 18px; + color: #718590; + font-size: 12px; + line-height: 1.55; +} + +.terminal-empty p span[lang='en'], +.host-key-dialog p span[lang='en'] { + color: color-mix(in srgb, currentColor 78%, transparent); + font-size: 0.92em; +} + +.terminal-empty .small-button { + border-color: #294137; + background: rgba(105, 230, 180, 0.08); + color: #69e6b4; +} + +.event-bar { + min-width: 0; + justify-content: space-between; + gap: 12px; + padding: 0 11px; + border-top: 1px solid #1c2932; + background: #0e151c; + color: #687b87; + font-family: var(--mono); + font-size: 9px; +} + +#event-message { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.event-bar button { + flex: 0 0 auto; + padding: 0; + border: 0; + background: transparent; + color: #8296a2; + font-family: inherit; + font-size: inherit; +} + +.event-log { + max-height: 126px; + overflow: auto; + padding: 8px 11px; + border-top: 1px solid #1c2932; + background: #0b1117; + color: #718590; + font-family: var(--mono); + font-size: 9px; + line-height: 1.7; +} + +.event-line { + display: grid; + grid-template-columns: 68px 120px minmax(0, 1fr); + gap: 7px; +} + +.event-line time { + color: #4f626e; +} + +.event-line strong { + overflow: hidden; + color: #69e6b4; + font-weight: 600; + overflow-wrap: anywhere; +} + +.event-line.error strong { + color: #ff7b82; +} + +.terminal-card:fullscreen { + width: 100vw; + height: 100vh; + border: 0; + border-radius: 0; +} + +.toast-region { + position: fixed; + right: max(18px, env(safe-area-inset-right)); + bottom: max(18px, env(safe-area-inset-bottom)); + z-index: 80; + display: grid; + width: min(340px, calc(100vw - 36px)); + gap: 8px; + pointer-events: none; +} + +.toast { + padding: 11px 13px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface-2); + box-shadow: var(--shadow); + color: var(--text); + font-size: 11px; + line-height: 1.45; + animation: toast-in 180ms ease-out; +} + +.toast.error { + border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); + color: var(--danger); +} + +.host-key-dialog { + width: min(520px, calc(100vw - 28px)); + max-height: calc(100dvh - 28px); + overflow-y: auto; + padding: 0; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.58); + color: var(--text); +} + +.host-key-dialog::backdrop { + background: rgba(2, 6, 9, 0.76); + backdrop-filter: blur(5px); +} + +.host-key-dialog form { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 14px 16px; + padding: 22px; +} + +.dialog-icon { + display: grid; + width: 46px; + height: 46px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--warning) 44%, var(--border)); + border-radius: 11px; + background: color-mix(in srgb, var(--warning) 10%, transparent); + color: var(--warning); + font-family: var(--mono); + font-size: 8px; + font-weight: 800; + line-height: 1.2; + text-align: center; +} + +.host-key-dialog h2 { + margin: 0 0 7px; + font-size: 18px; +} + +.host-key-dialog p:not(.eyebrow) { + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.55; +} + +.host-key-dialog dl, +.trust-check, +.dialog-actions { + grid-column: 1 / -1; +} + +.host-key-dialog dl { + display: grid; + gap: 1px; + overflow: hidden; + margin: 2px 0 0; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--border-soft); +} + + .host-key-dialog dl div { + display: grid; + grid-template-columns: 125px minmax(0, 1fr); + gap: 12px; + padding: 9px 11px; + background: var(--panel); +} + +.host-key-dialog dt { + color: var(--muted); + font-size: 10px; +} + +.host-key-dialog dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: var(--text); + font-family: var(--mono); + font-size: 10px; +} + +.trust-check { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 10px; +} + +.trust-check input { + accent-color: var(--accent); +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.dialog-actions .primary-button, +.dialog-actions .secondary-button { + flex: 0 0 auto; +} + +.mobile-only, +.panel-scrim { + display: none; +} + +@keyframes pulse { + 50% { opacity: 0.45; } +} + +@keyframes toast-in { + from { opacity: 0; transform: translateY(8px); } +} + +@media (max-width: 960px) { + .app-shell { + grid-template-columns: minmax(280px, 310px) minmax(0, 1fr); + } + + .session-metrics > div { + min-width: 72px; + padding: 0 7px; + } + + .session-metrics > div:first-child { + display: none; + } +} + +@media (max-width: 760px) { + :root { + --topbar-height: calc(58px + env(safe-area-inset-top)); + } + + .topbar { + padding-inline: max(12px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-right)); + } + + .app-shell { + display: block; + height: calc(100% - var(--topbar-height)); + } + + .mobile-only { + display: grid; + } + + .runtime-badge { + display: none; + } + + .connection-panel { + position: fixed; + top: var(--topbar-height); + bottom: 0; + left: 0; + z-index: 50; + width: min(90vw, 350px); + border-right: 1px solid var(--border); + box-shadow: var(--shadow); + transform: translateX(-102%); + transition: transform 220ms cubic-bezier(0.2, 0.75, 0.2, 1); + } + + .connection-panel.open { + transform: translateX(0); + } + + .panel-scrim { + position: fixed; + inset: var(--topbar-height) 0 0; + z-index: 40; + display: block; + width: auto; + padding: 0; + border: 0; + background: rgba(1, 5, 8, 0.68); + backdrop-filter: blur(3px); + } + + .workspace { + height: 100%; + gap: 8px; + padding: 8px; + } + + .session-strip { + padding: 9px 10px; + } + + .session-metrics > div { + display: none; + } + + .session-metrics > div:nth-child(2) { + display: block; + border-left: 0; + } + + .terminal-card { + border-radius: 9px; + } + + .terminal-toolbar { + grid-template-columns: 34px minmax(0, 1fr) auto; + } + + .window-controls i { + width: 7px; + height: 7px; + } + + .terminal-actions .toolbar-button:not(.danger) { + display: none; + } + + .terminal-stage { + padding: 7px 4px 5px 7px; + } + + .event-line { + grid-template-columns: 58px minmax(0, 1fr); + } + + .event-line strong { + display: none; + } +} + +@media (max-width: 480px) { + .brand > span:last-child { + display: none; + } + + .status-pill { + min-width: 0; + padding: 7px 9px; + } + + #global-status-label { + display: none; + } + + .terminal-label { + text-align: left; + } + + .field-grid.two { + grid-template-columns: 1fr; + } + + .host-key-dialog form { + grid-template-columns: 1fr; + padding: 18px; + } + + .dialog-icon { + display: none; + } + + .host-key-dialog dl, + .trust-check, + .dialog-actions { + grid-column: 1; + } + + .host-key-dialog dl div { + grid-template-columns: 1fr; + gap: 4px; + } + + .dialog-actions { + display: grid; + grid-template-columns: 1fr; + } + + .dialog-actions .primary-button, + .dialog-actions .secondary-button { + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0a933fe --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2594 @@ +{ + "name": "cf-workers-webssh", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cf-workers-webssh", + "version": "1.0.0", + "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.18.8", + "@cloudflare/workers-types": "^5.20260727.1", + "typescript": "^5.9.2", + "vite": "^7.0.6", + "vitest": "^4.1.10", + "wrangler": "^4.114.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vitest-pool-workers": { + "version": "0.18.8", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.18.8.tgz", + "integrity": "sha512-O1kOMZqapidlezNFiBZ7Lbd+8mMEpkGmWwPj+nPLvOngxSL11lmWq7xl7vxyjDxbeD/7l22KqgvRGM7XFaYd9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cjs-module-lexer": "1.2.3", + "esbuild": "0.28.1", + "miniflare": "4.20260722.0", + "wrangler": "4.114.0", + "zod": "3.25.76" + }, + "peerDependencies": { + "@vitest/runner": "^4.1.0", + "@vitest/snapshot": "^4.1.0", + "vitest": "^4.1.0" + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260722.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260722.1.tgz", + "integrity": "sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260722.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260722.1.tgz", + "integrity": "sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260722.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260722.1.tgz", + "integrity": "sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260722.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260722.1.tgz", + "integrity": "sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260722.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260722.1.tgz", + "integrity": "sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260727.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@cloudflare/workers-types/-/workers-types-5.20260727.1.tgz", + "integrity": "sha512-b/wT+LMZz0oELzxibww0ujFz5BD8NRz9WJ+xd+JNZJUMXgh8IHjpibKdGDvtkbotmihWUknP5tBPUU8KluLxxA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/miniflare": { + "version": "4.20260722.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/miniflare/-/miniflare-4.20260722.0.tgz", + "integrity": "sha512-LW6ABMhCx/yIEFBLC/DO4yAhdm2T/G7jp7pr5T2kj895+CCIaHZqpMXdW9O6YE48LcYcCJChwWc8aEs1vpbTXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.28.0", + "workerd": "1.20260722.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workerd": { + "version": "1.20260722.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/workerd/-/workerd-1.20260722.1.tgz", + "integrity": "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260722.1", + "@cloudflare/workerd-darwin-arm64": "1.20260722.1", + "@cloudflare/workerd-linux-64": "1.20260722.1", + "@cloudflare/workerd-linux-arm64": "1.20260722.1", + "@cloudflare/workerd-windows-64": "1.20260722.1" + } + }, + "node_modules/wrangler": { + "version": "4.114.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/wrangler/-/wrangler-4.114.0.tgz", + "integrity": "sha512-M65P25t5UHA1TIJfgZXDcj+YzVobgKdRguM2QPz0xnxLFuOcuE3ErgllDht0iaho7MS4o0g/Bb4YK2+GT+bibg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "4.20260722.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260722.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260722.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..961198c --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "cf-workers-webssh", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "A dependency-free-at-runtime SSH terminal for Cloudflare Workers", + "scripts": { + "dev": "npm run build:web && wrangler dev", + "dev:web": "vite --config vite.config.ts", + "build:web": "vite build --config vite.config.ts", + "typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.frontend.json --noEmit", + "test": "vitest run", + "test:edge": "vitest run --config vitest.edge.config.ts", + "check": "npm run typecheck && npm run test && npm run test:edge && npm run build:web && wrangler deploy --dry-run --outdir .wrangler/build", + "deploy": "npm run build:web && wrangler deploy" + }, + "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.18.8", + "@cloudflare/workers-types": "^5.20260727.1", + "typescript": "^5.9.2", + "vite": "^7.0.6", + "vitest": "^4.1.10", + "wrangler": "^4.114.0" + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/src/backend/durable-object.ts b/src/backend/durable-object.ts new file mode 100644 index 0000000..e331308 --- /dev/null +++ b/src/backend/durable-object.ts @@ -0,0 +1,201 @@ +import { connect } from 'cloudflare:sockets'; +import { parseConnectMessage, type Env } from '../types'; +import { assertPublicTarget } from './security'; +import { createTicket, verifyTicket } from './security'; +import { SSHSession } from './session'; + +interface Attachment { phase: 'waiting' | 'connecting' | 'connected' } +interface StoredTicket { secret: number[]; expiresAt: number; ip: string } +interface PendingConnection { + cancelled: boolean; + socket?: Socket; + closedSockets: WeakSet; +} +const TICKET_STORAGE_KEY = 'session-ticket'; + +export class SSHSessionDO implements DurableObject { + private readonly state: DurableObjectState; + private readonly env: Env; + private readonly sessions = new Map(); + private readonly pendingConnections = new Map(); + private readonly deadlines = 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') { + try { ws.close(1012, 'Worker session restarted; reconnect required'); } catch { /* already closed */ } + } else { + try { ws.close(1008, 'Session state expired'); } catch { /* already closed */ } + } + } + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === '/ticket' && request.method === 'POST') { + const ip = request.headers.get('x-client-ip') ?? 'unknown'; + if (!/^[0-9a-f:.]{2,64}$|^local$|^unknown$/i.test(ip)) return Response.json({ error: 'Invalid client address' }, { status: 400 }); + const secret = crypto.getRandomValues(new Uint8Array(32)); + const created = await createTicket(secret, ip); + const stored = await this.state.storage.transaction(async (tx) => { + if (await tx.get(TICKET_STORAGE_KEY)) return false; + await tx.put(TICKET_STORAGE_KEY, { secret: Array.from(secret), expiresAt: created.expiresAt, ip } satisfies StoredTicket); + await tx.setAlarm(created.expiresAt); + return true; + }); + secret.fill(0); + return stored ? Response.json(created) : Response.json({ error: 'Ticket already created' }, { status: 409 }); + } + 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 ticket = request.headers.get('x-session-ticket'); + const ip = request.headers.get('x-client-ip') ?? 'unknown'; + if (!ticket || !await this.consumeTicket(ticket, ip)) { + return Response.json({ error: 'Invalid session ticket' }, { status: 401 }); + } + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + this.state.acceptWebSocket(server); + server.serializeAttachment({ phase: 'waiting' } satisfies Attachment); + 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 { + try { + if (ws.readyState !== WebSocket.OPEN) { + this.cleanup(ws); + return; + } + const session = this.sessions.get(ws); + if (session) { + await session.handleClientMessage(message); + return; + } + if (this.pendingConnections.has(ws)) throw new Error('An SSH connection is already being initialized'); + if (typeof message !== 'string' || message.length > 160 * 1024) throw new Error('The first WebSocket message must be a connect JSON object'); + let decoded: unknown; + try { decoded = JSON.parse(message); } catch { throw new Error('Invalid connect JSON'); } + const config = parseConnectMessage(decoded); + const pending: PendingConnection = { cancelled: false, closedSockets: new WeakSet() }; + this.pendingConnections.set(ws, pending); + ws.serializeAttachment({ phase: 'connecting' } satisfies Attachment); + 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); + this.assertConnectionActive(ws, pending); + ws.send(JSON.stringify({ type: 'status', event: 'tcp_connecting', message: `Connecting to ${config.host}:${config.port}` })); + + // Connect to the exact address that passed SSRF validation. Keeping the + // original hostname only for host-key pinning prevents DNS rebinding. + const socket = await this.openVerifiedAddress(verifiedAddresses, config.port, pending); + this.assertConnectionActive(ws, pending, socket); + + const ssh = new SSHSession(ws, socket, config); + this.sessions.set(ws, ssh); + this.pendingConnections.delete(ws); + pending.socket = undefined; + ws.serializeAttachment({ phase: 'connected' } satisfies Attachment); + 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 alarm(): Promise { + await this.state.storage.delete(TICKET_STORAGE_KEY); + } + + private connectTimeout(): number { + const configured = Number(this.env.CONNECT_TIMEOUT_MS ?? 10_000); + return Number.isFinite(configured) ? Math.min(30_000, Math.max(2_000, Math.floor(configured))) : 10_000; + } + + private async consumeTicket(ticket: string, ip: string): Promise { + return this.state.storage.transaction(async (tx) => { + const stored = await tx.get(TICKET_STORAGE_KEY); + if (!stored) return false; + // A presented ticket is one-shot even when malformed, which closes the + // replay race without retaining authorization material after an attempt. + await tx.delete(TICKET_STORAGE_KEY); + await tx.deleteAlarm(); + if (stored.ip !== ip || stored.expiresAt < Date.now() || stored.secret.length !== 32) return false; + const secret = new Uint8Array(stored.secret); + try { + return await verifyTicket(secret, ticket, ip); + } finally { + secret.fill(0); + } + }); + } + + private async openVerifiedAddress(addresses: string[], port: number, pending: PendingConnection): Promise { + let lastError: unknown = new Error('No verified target address is available'); + for (const hostname of addresses) { + if (pending.cancelled) throw new Error('SSH connection attempt was cancelled'); + const socket = connect({ hostname, port }, { secureTransport: 'off', allowHalfOpen: false }); + pending.socket = socket; + let timeoutId: ReturnType | undefined; + try { + await Promise.race([ + socket.opened, + new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('TCP connection timed out')), this.connectTimeout()); }), + ]); + if (pending.cancelled) throw new Error('SSH connection attempt was cancelled'); + return socket; + } catch (error) { + lastError = error; + this.closePendingSocket(pending, socket); + if (pending.cancelled) throw error; + } finally { + if (timeoutId) clearTimeout(timeoutId); + } + } + throw lastError; + } + + private reject(ws: WebSocket, message: string): void { + try { ws.send(JSON.stringify({ type: 'error', event: 'connection_failed', message })); } catch { /* already closed */ } + this.cleanup(ws); + try { ws.close(1011, 'SSH connection failed'); } catch { /* already closed */ } + } + + private clearDeadline(ws: WebSocket): void { + const deadline = this.deadlines.get(ws); + if (deadline) clearTimeout(deadline); + this.deadlines.delete(ws); + } + + private cleanup(ws: WebSocket): void { + this.clearDeadline(ws); + const pending = this.pendingConnections.get(ws); + if (pending) { + pending.cancelled = true; + this.closePendingSocket(pending); + this.pendingConnections.delete(ws); + } + this.sessions.get(ws)?.close(true); + this.sessions.delete(ws); + } + + 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); + throw new Error('SSH connection attempt was cancelled'); + } + + private closePendingSocket(pending: PendingConnection, socket = pending.socket): void { + if (!socket || pending.closedSockets.has(socket)) return; + pending.closedSockets.add(socket); + if (pending.socket === socket) pending.socket = undefined; + try { void socket.close().catch(() => undefined); } catch { /* already closed */ } + } +} diff --git a/src/backend/security.ts b/src/backend/security.ts new file mode 100644 index 0000000..10f8e54 --- /dev/null +++ b/src/backend/security.ts @@ -0,0 +1,159 @@ +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const TICKET_TTL_MS = 60_000; + +interface TicketPayload { + exp: number; + nonce: string; + ip: string; +} + +function toBase64Url(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function fromBase64Url(value: string): Uint8Array { + if (!/^[A-Za-z0-9_-]*$/.test(value)) throw new Error('Invalid Base64URL value'); + const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4); + const binary = atob(padded); + if (toBase64Url(Uint8Array.from(binary, (char) => char.charCodeAt(0))) !== value) throw new Error('Non-canonical Base64URL value'); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +export function constantTimeEqual(left: string, right: string): boolean { + const a = encoder.encode(left); + const b = encoder.encode(right); + const length = Math.max(a.length, b.length); + let difference = a.length ^ b.length; + for (let index = 0; index < length; index++) difference |= (a[index] ?? 0) ^ (b[index] ?? 0); + return difference === 0; +} + +export async function createTicket(secret: Uint8Array, ip: string): Promise<{ ticket: string; expiresAt: number }> { + const expiresAt = Date.now() + TICKET_TTL_MS; + const payload: TicketPayload = { exp: expiresAt, nonce: crypto.randomUUID(), ip }; + const encodedPayload = toBase64Url(encoder.encode(JSON.stringify(payload))); + const key = await crypto.subtle.importKey('raw', new Uint8Array(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(encodedPayload))); + return { ticket: `${encodedPayload}.${toBase64Url(signature)}`, expiresAt }; +} + +export async function verifyTicket(secret: Uint8Array, ticket: string, ip: string): Promise { + const parts = ticket.split('.'); + if (parts.length !== 2 || parts[0].length > 1024 || parts[1].length > 128) return false; + try { + const key = await crypto.subtle.importKey('raw', new Uint8Array(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']); + const valid = await crypto.subtle.verify('HMAC', key, new Uint8Array(fromBase64Url(parts[1])), encoder.encode(parts[0])); + if (!valid) return false; + const payload = JSON.parse(decoder.decode(fromBase64Url(parts[0]))) as Partial; + return typeof payload.exp === 'number' && Number.isFinite(payload.exp) + && payload.exp >= Date.now() && payload.exp <= Date.now() + TICKET_TTL_MS + 5000 + && payload.ip === ip && typeof payload.nonce === 'string' + && /^[0-9a-f-]{36}$/i.test(payload.nonce); + } catch { + return false; + } +} + +export function isSameOrigin(request: Request): boolean { + const origin = request.headers.get('Origin'); + return origin !== null && origin === new URL(request.url).origin; +} + +function isIPv4(host: string): boolean { + return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(host) + && host.split('.').every((part) => Number(part) <= 255 && (part === '0' || !part.startsWith('0'))); +} + +function normalizeIPv6(host: string): string { + try { + return new URL(`http://[${host}]`).hostname.replace(/^\[|\]$/g, '').toLowerCase(); + } catch { + return host.toLowerCase(); + } +} + +export function isPrivateAddress(host: string): boolean { + const value = host.toLowerCase().replace(/^\[|\]$/g, ''); + if (value === 'localhost' || value.endsWith('.localhost') || value === '0.0.0.0' + || value === 'metadata.google.internal' || value.endsWith('.internal')) return true; + if (isIPv4(value)) { + const [a, b, c] = value.split('.').map(Number); + return a === 0 || a === 10 || a === 127 + || (a === 100 && b >= 64 && b <= 127) + || (a === 169 && b === 254) + || (a === 172 && b >= 16 && b <= 31) + || (a === 192 && b === 0 && c === 0) + || (a === 192 && b === 0 && c === 2) + || (a === 192 && b === 88 && c === 99) + || (a === 192 && b === 168) + || (a === 198 && (b === 18 || b === 19)) + || (a === 198 && b === 51 && c === 100) + || (a === 203 && b === 0 && c === 113) + || a >= 224; + } + if (!value.includes(':')) return false; + const ipv6 = normalizeIPv6(value); + if (ipv6 === '::' || ipv6 === '::1' + || /^f[cd]/.test(ipv6) + || /^fe[89ab]/.test(ipv6) + || ipv6.startsWith('2001:db8:') + || ipv6.startsWith('ff')) return true; + if (ipv6.startsWith('::ffff:')) { + const mapped = ipv6.slice('::ffff:'.length); + if (isIPv4(mapped)) return isPrivateAddress(mapped); + const halves = mapped.split(':'); + if (halves.length === 2 && halves.every((part) => /^[0-9a-f]{1,4}$/.test(part))) { + const numeric = (Number.parseInt(halves[0], 16) * 0x10000 + Number.parseInt(halves[1], 16)) >>> 0; + return isPrivateAddress(`${numeric >>> 24}.${numeric >>> 16 & 0xff}.${numeric >>> 8 & 0xff}.${numeric & 0xff}`); + } + return true; + } + return false; +} + +interface DnsAnswer { type: number; data: string } + +export async function resolvePublicAddresses(host: string): Promise { + if (host.includes(':') || isIPv4(host)) return [host]; + const endpoint = 'https://cloudflare-dns.com/dns-query'; + const headers = { Accept: 'application/dns-json' }; + const responses = await Promise.all(['A', 'AAAA'].map((type) => fetch(`${endpoint}?name=${encodeURIComponent(host)}&type=${type}`, { + headers, + redirect: 'manual', + signal: AbortSignal.timeout(5000), + }))); + const addresses: string[] = []; + for (const response of responses) { + if (!response.ok) throw new Error('Unable to verify the target DNS records'); + const responseUrl = new URL(response.url); + if (responseUrl.origin !== 'https://cloudflare-dns.com' + || !responseUrl.pathname.startsWith('/dns-query') + || !response.headers.get('Content-Type')?.toLowerCase().startsWith('application/dns-json')) { + throw new Error('Unexpected DNS resolver response'); + } + const result = await response.json<{ Status: number; Answer?: DnsAnswer[] }>(); + if (result.Status !== 0 && result.Status !== 3) throw new Error('Unable to verify the target DNS records'); + if ((result.Answer?.length ?? 0) > 64) throw new Error('Target DNS response has too many records'); + for (const answer of result.Answer ?? []) { + if (answer.type === 1 && isIPv4(answer.data)) addresses.push(answer.data); + if (answer.type === 28 && answer.data.includes(':')) { + const normalized = normalizeIPv6(answer.data); + if (!normalized.includes('%') && normalized.includes(':')) addresses.push(normalized); + } + } + } + return addresses; +} + +export async function assertPublicTarget(host: string): Promise { + if (isPrivateAddress(host)) throw new Error('Private and reserved SSH targets are disabled'); + const addresses = await resolvePublicAddresses(host); + if (addresses.length === 0) throw new Error('SSH target did not resolve to an address'); + for (const address of addresses) if (isPrivateAddress(address)) throw new Error('Target DNS resolves to a private or reserved address'); + const unique = [...new Set(addresses.map((address) => address.toLowerCase()))]; + if (unique.length > 16) throw new Error('SSH target resolves to too many addresses'); + return unique; +} diff --git a/src/backend/session.ts b/src/backend/session.ts new file mode 100644 index 0000000..c8c5e21 --- /dev/null +++ b/src/backend/session.ts @@ -0,0 +1,762 @@ +import { + SSH_MSG_CHANNEL_CLOSE, + SSH_MSG_CHANNEL_DATA, + SSH_MSG_CHANNEL_EOF, + SSH_MSG_CHANNEL_EXTENDED_DATA, + SSH_MSG_CHANNEL_FAILURE, + SSH_MSG_CHANNEL_OPEN_CONFIRMATION, + SSH_MSG_CHANNEL_OPEN_FAILURE, + SSH_MSG_CHANNEL_SUCCESS, + SSH_MSG_CHANNEL_WINDOW_ADJUST, + SSH_MSG_DEBUG, + SSH_MSG_DISCONNECT, + SSH_MSG_EXT_INFO, + SSH_MSG_GLOBAL_REQUEST, + SSH_MSG_IGNORE, + SSH_MSG_KEXINIT, + SSH_MSG_KEX_ECDH_REPLY, + SSH_MSG_NEWKEYS, + SSH_MSG_REQUEST_FAILURE, + SSH_MSG_REQUEST_SUCCESS, + SSH_MSG_SERVICE_ACCEPT, + SSH_MSG_SERVICE_REQUEST, + SSH_MSG_UNIMPLEMENTED, + SSH_MSG_USERAUTH_FAILURE, + SSH_MSG_USERAUTH_SUCCESS, + type SSHConnectionConfig, + type SSHPacket, + type SessionKeys, +} from '../types'; +import { isSSH2Identification, SSHTransport } from '../ssh/transport'; +import { SSHPacketBuilder, SSHPacketParser, nextSequenceNumber } from '../ssh/packet'; +import { KEXInitBuilder, filterExtInfo, negotiate, parseKEXInit, parseServerSigAlgs } from '../ssh/kex'; +import { + KEX_ALGORITHM_ECDH_NISTP256, + getCipherSpec, + getMacSpec, + isCurve25519KEXAlgorithm, +} from '../ssh/algorithms'; +import { ECDHKeyExchange } from '../ssh/kex-ecdh'; +import { Curve25519KeyExchange, type Curve25519KeyPair } from '../ssh/kex-curve25519'; +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 { encodeString, readUint32, toBufferSource } from '../ssh/utils'; + +type Cipher = SSHAESGCMCipher | SSHAESCTRCipher; +type Phase = 'version' | 'kex' | 'host-confirm' | 'auth' | 'pty' | 'shell' | 'ready' | 'closed'; +const LOCAL_WINDOW_THRESHOLD = 512 * 1024; +const MAX_VERSION_BYTES = 8192; +const MAX_QUEUED_INPUT = 1024 * 1024; +const KEEPALIVE_NAME = new TextEncoder().encode('keepalive@openssh.com'); + +export class SSHSession { + private readonly encoder = new TextEncoder(); + private readonly decoder = new TextDecoder(); + private readonly transport = new SSHTransport(); + private readonly parser = new SSHPacketParser(); + private readonly channel = new SSHChannel(); + private readonly config: SSHConnectionConfig; + private readonly ws: WebSocket; + private readonly socket: Socket; + private phase: Phase = 'version'; + private versionBuffer = new Uint8Array(); + private writer: WritableStreamDefaultWriter | null = null; + private sendSequence = 0; + private sendChain: Promise = Promise.resolve(); + private localKex: Uint8Array | null = null; + private remoteKex: Uint8Array | null = null; + private kexName: string | null = null; + private hostKeyAlgorithm: string | null = null; + private ecdhPair: CryptoKeyPair | null = null; + private curvePair: Curve25519KeyPair | null = null; + private clientPublic: Uint8Array | null = null; + private sessionId: Uint8Array | null = null; + private keys: SessionKeys | null = null; + private cipherC2S = 'aes128-gcm@openssh.com'; + private cipherS2C = 'aes128-gcm@openssh.com'; + private macC2S = 'none'; + private macS2C = 'none'; + private encryptor: Cipher | null = null; + private decryptor: Cipher | null = null; + private signer: SSHHMAC | null = null; + private verifier: SSHHMAC | null = null; + private serverSigAlgs: string[] = []; + private authRequestSent = false; + private pendingChannelRequest: 'pty' | 'shell' | null = null; + private ignoreNextKexPacket = false; + private inputQueue: Uint8Array[] = []; + private queueHeadOffset = 0; + private queuedBytes = 0; + private flushingInput = false; + private keepaliveTimer: ReturnType | null = null; + private keepalivePending = 0; + private shellTimer: ReturnType | null = null; + private readTask: Promise | null = null; + private pendingHostConfirmation: { fingerprint: string; resolve: (accepted: boolean) => void } | null = null; + + constructor(ws: WebSocket, socket: Socket, config: SSHConnectionConfig) { + this.ws = ws; + this.socket = socket; + this.config = config; + } + + async start(): Promise { + this.status('version_exchange', 'Exchanging SSH protocol versions'); + await this.write(this.transport.getLocalIdentification()); + this.readTask = this.readLoop(); + } + + async handleClientMessage(message: string | ArrayBuffer): Promise { + if (this.phase === 'closed') return; + if (message instanceof ArrayBuffer) { + if (message.byteLength > 64 * 1024) throw new Error('Binary terminal input exceeds 64 KiB'); + this.queueInput(new Uint8Array(message)); + return; + } + + let frame: unknown; + try { frame = JSON.parse(message); } catch { throw new Error('Invalid WebSocket control JSON'); } + if (!frame || typeof frame !== 'object' || Array.isArray(frame)) throw new Error('Invalid WebSocket control message'); + const value = frame as Record; + if (value.type === 'host_key_decision') { + const pending = this.pendingHostConfirmation; + if (!pending || typeof value.accept !== 'boolean' || value.fingerprint !== pending.fingerprint) throw new Error('Invalid host key decision'); + this.pendingHostConfirmation = null; + pending.resolve(value.accept); + return; + } + if (value.type === 'ping') { + this.sendJson({ type: 'pong' }); + return; + } + const resize = value.type === 'resize' + ? [value.cols, value.rows] + : Array.isArray(value.resize) ? value.resize : null; + if (resize) { + await this.resize(resize[0], resize[1]); + return; + } + if (value.type === 'input' || (value.type === undefined && typeof value.data === 'string')) { + if (typeof value.data !== 'string' || value.data.length > 256 * 1024) throw new Error('Invalid terminal input'); + this.queueInput(this.encoder.encode(value.data)); + return; + } + throw new Error('Unsupported WebSocket message'); + } + + close(normal = false): void { + if (this.phase === 'closed') return; + this.phase = 'closed'; + if (this.keepaliveTimer) clearInterval(this.keepaliveTimer); + if (this.shellTimer) clearTimeout(this.shellTimer); + this.keepaliveTimer = null; + this.shellTimer = null; + this.inputQueue = []; + this.queuedBytes = 0; + this.pendingHostConfirmation?.resolve(false); + this.pendingHostConfirmation = null; + try { this.writer?.releaseLock(); } catch { /* already released */ } + this.writer = null; + try { this.socket.close(); } catch { /* already closed */ } + try { this.ws.close(normal ? 1000 : 1011, normal ? 'Session closed' : 'SSH session failed'); } catch { /* already closed */ } + } + + private async readLoop(): Promise { + const reader = this.socket.readable.getReader(); + try { + while (this.phase !== 'closed') { + const { value, done } = await reader.read(); + if (done) { + this.status('remote_closed', 'The SSH server closed the connection'); + this.close(true); + return; + } + if (value.length > 0) await this.consume(value); + } + } catch (error) { + this.fail(error, 'read_error'); + } finally { + try { reader.releaseLock(); } catch { /* ignored */ } + } + } + + private async consume(data: Uint8Array): Promise { + if (this.phase === 'version') { + const merged = new Uint8Array(this.versionBuffer.length + data.length); + merged.set(this.versionBuffer); + merged.set(data, this.versionBuffer.length); + if (merged.length > MAX_VERSION_BYTES) throw new Error('SSH identification banner is too large'); + this.versionBuffer = merged; + let start = 0; + while (true) { + const newline = this.versionBuffer.indexOf(0x0a, start); + if (newline < 0) return; + const bytes = this.versionBuffer.subarray(start, newline + 1); + start = newline + 1; + const line = this.decoder.decode(bytes).replace(/\r?\n$/, ''); + if (!line.startsWith('SSH-')) continue; + if (!isSSH2Identification(line)) throw new Error('The server does not support SSH 2.0'); + this.transport.handleVersionExchange(`${line}\r\n`); + const remaining = this.versionBuffer.subarray(start); + this.versionBuffer = new Uint8Array(); + this.phase = 'kex'; + this.status('version_ready', 'Version exchange complete; negotiating keys'); + await this.startKex(); + if (remaining.length > 0) { + this.parser.feed(remaining); + await this.processPackets(); + } + return; + } + } + this.parser.feed(data); + await this.processPackets(); + } + + private async startKex(): Promise { + this.localKex = KEXInitBuilder.build(); + await this.sendPlain(this.localKex); + } + + private async processPackets(): Promise { + while (this.phase !== 'closed') { + // NEWKEYS may enable inbound encryption while this same TCP chunk still + // contains encrypted packets, so recompute framing on every iteration. + const spec = this.decryptor ? getCipherSpec(this.cipherS2C) : null; + const macLength = this.decryptor && !spec?.aead ? getMacSpec(this.macS2C).length : 0; + const packet = await this.parser.nextPacket( + spec?.blockSize ?? 8, + this.decryptor ? (data, sequence, aad, commit) => this.decryptor!.decrypt(data, sequence, aad, commit) : (data) => data, + Boolean(spec?.aead), + macLength, + this.verifier ? (data, mac, sequence) => this.verifier!.verify(data, sequence, mac) : undefined, + ); + if (!packet) return; + await this.handlePacket(packet); + } + } + + private async handlePacket(packet: SSHPacket): Promise { + const type = packet.payload[0]; + if (type === SSH_MSG_DISCONNECT) { + this.validateDisconnect(packet.payload); + this.status('remote_closed', 'The SSH server closed the connection'); + this.close(true); + return; + } + if (type === SSH_MSG_IGNORE) { + const data = this.readBytes(packet.payload, 1); + if (data.next !== packet.payload.length) throw new Error('Malformed SSH ignore message'); + return; + } + if (type === SSH_MSG_DEBUG) { + this.validateDebug(packet.payload); + return; + } + if (type === SSH_MSG_UNIMPLEMENTED) { + if (packet.payload.length !== 5) throw new Error('Malformed SSH unimplemented message'); + return; + } + if (type === SSH_MSG_GLOBAL_REQUEST) { + await this.handleGlobalRequest(packet.payload); + return; + } + if (type === SSH_MSG_REQUEST_SUCCESS || type === SSH_MSG_REQUEST_FAILURE) { + this.keepalivePending = 0; + return; + } + if (type === SSH_MSG_KEXINIT && this.phase !== 'kex' && this.phase !== 'host-confirm') throw new Error('Server-initiated SSH rekey is not supported'); + if (this.phase === 'kex' || this.phase === 'host-confirm') await this.handleKex(type, packet.payload); + else if (this.phase === 'auth') await this.handleAuth(type, packet.payload); + else if (this.phase === 'pty' || this.phase === 'shell' || this.phase === 'ready') await this.handleChannel(type, packet.payload); + } + + private async handleKex(type: number, payload: Uint8Array): Promise { + if (this.ignoreNextKexPacket) { + this.ignoreNextKexPacket = false; + return; + } + if (type === SSH_MSG_KEXINIT) { + if (this.encryptor) throw new Error('SSH rekey is not supported by this terminal session'); + this.remoteKex = payload; + const client = parseKEXInit(this.localKex!); + const server = parseKEXInit(payload); + this.kexName = negotiate(filterExtInfo(client.kexAlgorithms), filterExtInfo(server.kexAlgorithms), 'key exchange algorithm'); + this.hostKeyAlgorithm = negotiate(client.hostKeyAlgorithms, server.hostKeyAlgorithms, 'host key algorithm'); + this.ignoreNextKexPacket = server.firstKexPacketFollows + && (filterExtInfo(server.kexAlgorithms)[0] !== this.kexName || server.hostKeyAlgorithms[0] !== this.hostKeyAlgorithm); + this.cipherC2S = negotiate(client.encryptionC2S, server.encryptionC2S, 'client cipher'); + this.cipherS2C = negotiate(client.encryptionS2C, server.encryptionS2C, 'server cipher'); + // RFC 4253 negotiates MAC lists even when the chosen AEAD cipher does + // not use the result on the wire. + const negotiatedMacC2S = negotiate(client.macC2S, server.macC2S, 'client MAC'); + const negotiatedMacS2C = negotiate(client.macS2C, server.macS2C, 'server MAC'); + this.macC2S = getCipherSpec(this.cipherC2S).aead ? 'none' : negotiatedMacC2S; + this.macS2C = getCipherSpec(this.cipherS2C).aead ? 'none' : negotiatedMacS2C; + if (negotiate(client.compressionC2S, server.compressionC2S, 'client compression') !== 'none' + || negotiate(client.compressionS2C, server.compressionS2C, 'server compression') !== 'none') { + throw new Error('SSH compression is not supported'); + } + await this.sendEcdhInit(); + return; + } + if (type === SSH_MSG_KEX_ECDH_REPLY) { + await this.handleEcdhReply(payload); + return; + } + if (type === SSH_MSG_NEWKEYS) { + if (!this.keys || !this.encryptor) throw new Error('Received NEWKEYS before completing key exchange'); + // The server's new keys apply only to subsequent inbound packets. + await this.enableInboundEncryption(); + this.phase = 'auth'; + this.status('authenticating', 'Encrypted transport established; authenticating'); + await this.sendEncrypted(new Uint8Array([SSH_MSG_SERVICE_REQUEST, ...encodeString('ssh-userauth')])); + } + } + + private async sendEcdhInit(): Promise { + if (this.kexName && isCurve25519KEXAlgorithm(this.kexName)) { + this.curvePair = await Curve25519KeyExchange.generateKeyPair(); + this.clientPublic = await Curve25519KeyExchange.exportRawPublicKey(this.curvePair); + await this.sendPlain(Curve25519KeyExchange.buildInit(this.clientPublic)); + return; + } + if (this.kexName === KEX_ALGORITHM_ECDH_NISTP256) { + this.ecdhPair = await ECDHKeyExchange.generateKeyPair(); + this.clientPublic = await ECDHKeyExchange.exportRawPublicKey(this.ecdhPair); + await this.sendPlain(ECDHKeyExchange.buildInit(this.clientPublic)); + return; + } + throw new Error(`Unsupported key exchange algorithm: ${this.kexName ?? 'none'}`); + } + + private async handleEcdhReply(payload: Uint8Array): Promise { + if (!this.kexName || !this.clientPublic || !this.localKex || !this.remoteKex) throw new Error('Unexpected key exchange reply'); + const { hostKey, serverRawPublicKey, signature } = ECDHKeyExchange.parseReply(payload); + let sharedSecret: Uint8Array; + let hash: Uint8Array; + if (isCurve25519KEXAlgorithm(this.kexName)) { + if (!this.curvePair) throw new Error('Missing Curve25519 key'); + sharedSecret = await Curve25519KeyExchange.computeSharedSecret(this.curvePair.privateKey, serverRawPublicKey); + hash = await Curve25519KeyExchange.computeExchangeHash(this.transport.getLocalVersion(), this.transport.getRemoteVersion(), this.localKex, this.remoteKex, hostKey, this.clientPublic, serverRawPublicKey, sharedSecret); + } else { + if (!this.ecdhPair) throw new Error('Missing ECDH key'); + sharedSecret = await ECDHKeyExchange.computeSharedSecret(this.ecdhPair.privateKey, serverRawPublicKey); + hash = await ECDHKeyExchange.computeExchangeHash(this.transport.getLocalVersion(), this.transport.getRemoteVersion(), this.localKex, this.remoteKex, hostKey, this.clientPublic, serverRawPublicKey, sharedSecret); + } + const keyTypeLength = readUint32(hostKey, 0); + if (keyTypeLength > 128 || keyTypeLength + 4 > hostKey.length) throw new Error('Malformed SSH host key'); + const keyType = this.decoder.decode(hostKey.subarray(4, 4 + keyTypeLength)); + if (!this.isHostKeyAlgorithmCompatible(this.hostKeyAlgorithm, keyType)) throw new Error(`Server used ${keyType}, but negotiated ${this.hostKeyAlgorithm ?? 'no host key algorithm'}`); + const fingerprint = `SHA256:${this.base64(new Uint8Array(await crypto.subtle.digest('SHA-256', toBufferSource(hostKey))))}`; + if (this.config.expectedFingerprint && this.config.expectedFingerprint !== fingerprint) throw new Error(`Host key mismatch: expected ${this.config.expectedFingerprint}, received ${fingerprint}`); + if (!await this.verifyHostSignature(hostKey, signature, hash)) throw new Error('SSH host key signature verification failed'); + if (this.config.expectedFingerprint) { + this.sendJson({ type: 'host_key', fingerprint, keyType, trusted: true }); + } else if (!await this.confirmHostKey(fingerprint, keyType)) { + throw new Error('Host key was not accepted'); + } + this.status('host_key_verified', `Host key verified (${keyType})`); + + this.sessionId ??= hash; + const c2s = getCipherSpec(this.cipherC2S); + const s2c = getCipherSpec(this.cipherS2C); + this.keys = await KeyDerivation.deriveKeys( + sharedSecret, + hash, + this.sessionId, + c2s.ivLength, + s2c.ivLength, + getMacSpec(this.macC2S).keyLength, + getMacSpec(this.macS2C).keyLength, + c2s.keyLength, + s2c.keyLength, + ); + // Each direction switches independently at its NEWKEYS boundary. Sending + // ours now also works with servers that wait before sending their NEWKEYS. + await this.sendPlain(new Uint8Array([SSH_MSG_NEWKEYS])); + await this.enableOutboundEncryption(); + } + + private async confirmHostKey(fingerprint: string, keyType: string): Promise { + this.phase = 'host-confirm'; + this.status('host_key_confirmation', 'Confirm this host key before credentials are sent'); + return new Promise((resolve) => { + let settled = false; + const finish = (accepted: boolean): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (this.pendingHostConfirmation?.fingerprint === fingerprint) this.pendingHostConfirmation = null; + if (accepted && this.phase !== 'closed') this.phase = 'kex'; + resolve(accepted); + }; + const timeout = setTimeout(() => finish(false), 30_000); + this.pendingHostConfirmation = { fingerprint, resolve: finish }; + // Register the decision handler before notifying the browser. + this.sendJson({ type: 'host_key', fingerprint, keyType, trusted: false }); + }); + } + + private async verifyHostSignature(hostKey: Uint8Array, signature: Uint8Array, hash: Uint8Array): Promise { + let offset = 0; + const keyType = this.readString(hostKey, offset); offset = keyType.next; + let sigOffset = 0; + const signatureType = this.readString(signature, sigOffset); sigOffset = signatureType.next; + const signatureField = this.readBytes(signature, sigOffset); + if (signatureField.next !== signature.length) throw new Error('Malformed SSH host signature'); + const rawSignature = signatureField.value; + if (keyType.value === 'ssh-ed25519') { + if (this.hostKeyAlgorithm !== 'ssh-ed25519' || signatureType.value !== 'ssh-ed25519') throw new Error('Ed25519 host key or signature does not match the negotiated algorithm'); + const keyField = this.readBytes(hostKey, offset); + if (keyField.next !== hostKey.length || keyField.value.length !== 32 || rawSignature.length !== 64) throw new Error('Malformed Ed25519 host key or signature'); + const rawKey = keyField.value; + const key = await crypto.subtle.importKey('raw', toBufferSource(rawKey), { name: 'Ed25519' }, false, ['verify']); + return crypto.subtle.verify('Ed25519', key, toBufferSource(rawSignature), toBufferSource(hash)); + } + if (keyType.value.startsWith('ecdsa-sha2-nistp')) { + if (this.hostKeyAlgorithm !== keyType.value || signatureType.value !== keyType.value) throw new Error('ECDSA host key or signature does not match the negotiated algorithm'); + const curveName = this.readString(hostKey, offset); offset = curveName.next; + const keyField = this.readBytes(hostKey, offset); + if (keyField.next !== hostKey.length) throw new Error('Malformed ECDSA host key'); + const rawKey = keyField.value; + const curve = keyType.value.endsWith('256') ? 'P-256' : keyType.value.endsWith('384') ? 'P-384' : 'P-521'; + if (curveName.value !== keyType.value.replace('ecdsa-sha2-', '')) throw new Error('ECDSA curve name does not match the host key type'); + const digest = curve === 'P-256' ? 'SHA-256' : curve === 'P-384' ? 'SHA-384' : 'SHA-512'; + const coordinateBytes = curve === 'P-256' ? 32 : curve === 'P-384' ? 48 : 66; + const key = await crypto.subtle.importKey('raw', toBufferSource(rawKey), { name: 'ECDSA', namedCurve: curve }, false, ['verify']); + return crypto.subtle.verify({ name: 'ECDSA', hash: digest }, key, toBufferSource(this.sshEcdsaToRaw(rawSignature, coordinateBytes)), toBufferSource(hash)); + } + if (keyType.value === 'ssh-rsa') { + if (signatureType.value !== 'rsa-sha2-256' && signatureType.value !== 'rsa-sha2-512') throw new Error(`Unsupported RSA host signature type: ${signatureType.value}`); + if (signatureType.value !== this.hostKeyAlgorithm) throw new Error(`RSA signature ${signatureType.value} does not match negotiated ${this.hostKeyAlgorithm}`); + const exponent = this.readBytes(hostKey, offset); offset = exponent.next; + const modulusField = this.readBytes(hostKey, offset); + if (modulusField.next !== hostKey.length || exponent.value.length === 0 || modulusField.value.length === 0) throw new Error('Malformed RSA host key'); + const modulus = modulusField.value; + const digest = signatureType.value === 'rsa-sha2-512' ? 'SHA-512' : 'SHA-256'; + const key = await crypto.subtle.importKey('jwk', { kty: 'RSA', e: this.base64UrlUnsigned(exponent.value), n: this.base64UrlUnsigned(modulus), ext: true }, { name: 'RSASSA-PKCS1-v1_5', hash: digest }, false, ['verify']); + return crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, toBufferSource(rawSignature), toBufferSource(hash)); + } + throw new Error(`Unsupported host key type: ${keyType.value}`); + } + + private async enableOutboundEncryption(): Promise { + if (!this.keys) throw new Error('Session keys are not available'); + const c2s = getCipherSpec(this.cipherC2S); + this.encryptor = c2s.mode === 'gcm' + ? new SSHAESGCMCipher(this.keys.encKeyClientToServer.subarray(0, c2s.keyLength), this.keys.ivClientToServer) + : new SSHAESCTRCipher(this.keys.encKeyClientToServer.subarray(0, c2s.keyLength), this.keys.ivClientToServer); + await this.encryptor.init(); + if (!c2s.aead) { this.signer = new SSHHMAC(this.macC2S, this.keys.integrityKeyC2S); await this.signer.init(); } + } + + private async enableInboundEncryption(): Promise { + if (!this.keys) throw new Error('Session keys are not available'); + const s2c = getCipherSpec(this.cipherS2C); + this.decryptor = s2c.mode === 'gcm' + ? new SSHAESGCMCipher(this.keys.encKeyServerToClient.subarray(0, s2c.keyLength), this.keys.ivServerToClient) + : new SSHAESCTRCipher(this.keys.encKeyServerToClient.subarray(0, s2c.keyLength), this.keys.ivServerToClient); + await this.decryptor.init(); + if (!s2c.aead) { this.verifier = new SSHHMAC(this.macS2C, this.keys.integrityKeyS2C); await this.verifier.init(); } + } + + private async handleAuth(type: number, payload: Uint8Array): Promise { + if (type === SSH_MSG_EXT_INFO) { + if (this.authRequestSent) throw new Error('Unexpected SSH extension information after authentication started'); + this.serverSigAlgs = parseServerSigAlgs(payload); + return; + } + if (type === SSH_MSG_SERVICE_ACCEPT) { + const service = this.readString(payload, 1); + if (service.next !== payload.length || service.value !== 'ssh-userauth') throw new Error('Invalid SSH user authentication service acceptance'); + if (this.authRequestSent) throw new Error('Duplicate SSH user authentication service acceptance'); + const request = this.config.authMethod === 'publickey' + ? await SSHAuth.buildPublicKeyAuthRequest(this.config.username, this.config.privateKey!, this.sessionId!, this.serverSigAlgs, false) + : SSHAuth.buildPasswordAuthRequest(this.config.username, this.config.password!); + this.authRequestSent = true; + this.config.password = undefined; + this.config.privateKey = undefined; + await this.sendEncrypted(request); + return; + } + if (type === SSH_MSG_USERAUTH_SUCCESS) { + if (payload.length !== 1) throw new Error('Malformed SSH authentication success'); + if (!this.authRequestSent) throw new Error('SSH authentication completed before credentials were sent'); + this.status('auth_success', 'SSH authentication succeeded; opening terminal'); + this.phase = 'pty'; + this.startKeepalive(); + await this.sendEncrypted(this.channel.buildOpenSession(0)); + return; + } + if (type === SSH_MSG_USERAUTH_FAILURE) { + if (!this.authRequestSent) throw new Error('SSH authentication failed before credentials were sent'); + this.validateAuthFailure(payload); + throw new Error('SSH authentication failed'); + } + } + + private async handleChannel(type: number, payload: Uint8Array): Promise { + 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)); + return; + } + if (type === SSH_MSG_CHANNEL_OPEN_FAILURE) { + this.channel.handleOpenFailure(payload); + throw new Error('SSH server rejected the session channel'); + } + 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(); + 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(); + 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(); + return; + } + if (type === SSH_MSG_CHANNEL_WINDOW_ADJUST) { + this.channel.handleWindowAdjust(payload); + void this.flushInput(); + return; + } + if (type === SSH_MSG_CHANNEL_EOF) { + this.channel.handleEof(payload); + this.status('remote_eof', 'SSH server finished sending output'); + 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); + return; + } + } + + private markReady(): void { + if (this.phase === 'ready' || this.phase === 'closed') return; + if (this.shellTimer) clearTimeout(this.shellTimer); + this.shellTimer = null; + this.pendingChannelRequest = null; + this.phase = 'ready'; + this.sendJson({ + type: 'ready', + negotiated: { kex: this.kexName, cipherC2S: this.cipherC2S, cipherS2C: this.cipherS2C, macC2S: this.macC2S, macS2C: this.macS2C }, + }); + this.status('shell_ready', 'Shell is ready'); + void this.flushInput(); + } + + private queueInput(data: Uint8Array): void { + if (data.length === 0) return; + if (this.phase !== 'ready') throw new Error('Terminal is not ready'); + if (this.queuedBytes + data.length > MAX_QUEUED_INPUT) throw new Error('Terminal input queue limit exceeded'); + this.inputQueue.push(data); + this.queuedBytes += data.length; + void this.flushInput(); + } + + private async flushInput(): Promise { + if (this.flushingInput || this.phase !== 'ready') return; + this.flushingInput = true; + try { + while (this.inputQueue.length > 0 && this.phase === 'ready') { + const first = this.inputQueue[0]; + const chunk = this.channel.takeChannelDataChunk(first, this.queueHeadOffset); + if (!chunk) return; + await this.sendChannelData(chunk); + this.queueHeadOffset += chunk.bytesConsumed; + this.queuedBytes -= chunk.bytesConsumed; + if (this.queueHeadOffset === first.length) { + this.inputQueue.shift(); + this.queueHeadOffset = 0; + } + } + } finally { + this.flushingInput = false; + } + } + + 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)); + } + + private async adjustLocalWindow(): Promise { + const amount = this.channel.takeLocalWindowAdjustment(LOCAL_WINDOW_THRESHOLD); + if (amount !== null) await this.sendEncrypted(this.channel.buildWindowAdjust(amount)); + } + + private async handleGlobalRequest(payload: Uint8Array): Promise { + if (payload.length < 6) throw new Error('Malformed global request'); + const nameLength = readUint32(payload, 1); + if (5 + nameLength >= payload.length) throw new Error('Malformed global request'); + try { new TextDecoder('utf-8', { fatal: true }).decode(payload.subarray(5, 5 + nameLength)); } + catch { throw new Error('Malformed global request name'); } + if (payload[5 + nameLength] > 1) throw new Error('Malformed global request reply flag'); + const wantsReply = payload[5 + nameLength] === 1; + if (wantsReply && this.encryptor) await this.sendEncrypted(new Uint8Array([SSH_MSG_REQUEST_FAILURE])); + } + + private startKeepalive(): void { + this.keepaliveTimer = setInterval(() => { + if (this.phase === 'closed') return; + if (this.keepalivePending >= 3) { + this.fail(new Error('SSH keepalive timed out'), 'keepalive_timeout'); + return; + } + this.keepalivePending++; + const payload = new Uint8Array(1 + 4 + KEEPALIVE_NAME.length + 1); + payload[0] = SSH_MSG_GLOBAL_REQUEST; + new DataView(payload.buffer).setUint32(1, KEEPALIVE_NAME.length, false); + payload.set(KEEPALIVE_NAME, 5); + payload[payload.length - 1] = 1; + void this.sendEncrypted(payload).catch((error) => this.fail(error, 'keepalive_failed')); + }, 25_000); + } + + private async sendPlain(payload: Uint8Array): Promise { + await this.serialSend(async () => { + const packet = await SSHPacketBuilder.build(payload, 8, null, this.sendSequence); + this.sendSequence = nextSequenceNumber(this.sendSequence); + await this.write(packet); + }); + } + + private async sendEncrypted(payload: Uint8Array): 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.build(payload, 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); + }); + } + + private async sendChannelData(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); + this.sendSequence = nextSequenceNumber(this.sendSequence); + await this.write(packet); + }); + } + + private async serialSend(operation: () => Promise): Promise { + const next = this.sendChain.then(operation); + this.sendChain = next.catch(() => undefined); + await next; + } + + private async write(data: Uint8Array): Promise { + const writer = this.writer ?? this.socket.writable.getWriter(); + this.writer = writer; + await writer.write(data); + } + + private readBytes(bytes: Uint8Array, offset: number): { value: Uint8Array; next: number } { + if (offset + 4 > bytes.length) throw new Error('Malformed SSH key data'); + const length = readUint32(bytes, offset); + if (length > bytes.length - offset - 4) throw new Error('Malformed SSH key data'); + return { value: bytes.subarray(offset + 4, offset + 4 + length), next: offset + 4 + length }; + } + + private readString(bytes: Uint8Array, offset: number): { value: string; next: number } { + const field = this.readBytes(bytes, offset); + return { value: this.decoder.decode(field.value), next: field.next }; + } + + private sshEcdsaToRaw(signature: Uint8Array, coordinateBytes: number): Uint8Array { + const rField = this.readBytes(signature, 0); + const sField = this.readBytes(signature, rField.next); + if (sField.next !== signature.length) throw new Error('Malformed ECDSA signature trailing data'); + const trim = (value: Uint8Array): Uint8Array => { + if (value.length === 0 || (value[0] & 0x80) !== 0) throw new Error('ECDSA signature integers must be positive'); + if (value.length > 1 && value[0] === 0 && (value[1] & 0x80) === 0) throw new Error('ECDSA signature integer is not minimally encoded'); + const normalized = value.length > 1 && value[0] === 0 ? value.subarray(1) : value; + if (normalized.every((byte) => byte === 0)) throw new Error('ECDSA signature integers must be non-zero'); + return normalized; + }; + const r = trim(rField.value); const s = trim(sField.value); + if (r.length > coordinateBytes || s.length > coordinateBytes) throw new Error('Invalid ECDSA signature'); + const result = new Uint8Array(coordinateBytes * 2); + result.set(r, coordinateBytes - r.length); + result.set(s, coordinateBytes * 2 - s.length); + return result; + } + + private base64(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/=+$/, ''); + } + + private base64UrlUnsigned(bytes: Uint8Array): string { + let start = 0; + while (start < bytes.length - 1 && bytes[start] === 0) start++; + return this.base64(bytes.subarray(start)).replace(/\+/g, '-').replace(/\//g, '_'); + } + + private validateDisconnect(payload: Uint8Array): void { + if (payload.length < 13) throw new Error('Malformed SSH disconnect message'); + let offset = 5; + offset = this.readBytes(payload, offset).next; + offset = this.readBytes(payload, offset).next; + if (offset !== payload.length) throw new Error('Malformed SSH disconnect message'); + } + + private validateDebug(payload: Uint8Array): void { + if (payload.length < 10 || payload[1] > 1) throw new Error('Malformed SSH debug message'); + let offset = 2; + offset = this.readBytes(payload, offset).next; + offset = this.readBytes(payload, offset).next; + if (offset !== payload.length) throw new Error('Malformed SSH debug message'); + } + + private validateAuthFailure(payload: Uint8Array): void { + if (payload.length < 6) throw new Error('Malformed SSH authentication failure'); + const methodsLength = readUint32(payload, 1); + if (methodsLength > payload.length - 6 || methodsLength + 6 !== payload.length) { + throw new Error('Malformed SSH authentication failure'); + } + } + + private isHostKeyAlgorithmCompatible(negotiated: string | null, keyType: string): boolean { + if (!negotiated) return false; + if (negotiated === keyType) return true; + return keyType === 'ssh-rsa' && (negotiated === 'rsa-sha2-256' || negotiated === 'rsa-sha2-512'); + } + + private status(event: string, message: string): void { this.sendJson({ type: 'status', event, message }); } + private sendJson(value: unknown): void { if (this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(value)); } + private fail(error: unknown, event: string): void { + const message = error instanceof Error ? error.message : String(error); + this.sendJson({ type: 'error', event, message }); + this.close(); + } +} diff --git a/src/http-security.ts b/src/http-security.ts new file mode 100644 index 0000000..9fd30cf --- /dev/null +++ b/src/http-security.ts @@ -0,0 +1,29 @@ +export function secureResponse(response: Response): Response { + const headers = new Headers(response.headers); + headers.set('Strict-Transport-Security', 'max-age=31536000'); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('X-Frame-Options', 'DENY'); + headers.set('Referrer-Policy', 'same-origin'); + headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + if (response.headers.get('Content-Type')?.includes('text/html')) { + headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"); + } + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +export function jsonError(error: string, status: number): Response { + return secureResponse(Response.json({ error }, { status, headers: { 'Cache-Control': 'no-store' } })); +} + +export function isProductionHttp(request: Request): boolean { + return new URL(request.url).protocol === 'http:' && request.headers.has('CF-Connecting-IP'); +} + +export function httpsRedirect(request: Request): Response { + const url = new URL(request.url); + url.protocol = 'https:'; + return secureResponse(new Response(null, { + status: 308, + headers: { Location: url.toString(), 'Cache-Control': 'no-store' }, + })); +} diff --git a/src/ssh/algorithms.ts b/src/ssh/algorithms.ts new file mode 100644 index 0000000..7f1b935 --- /dev/null +++ b/src/ssh/algorithms.ts @@ -0,0 +1,68 @@ +export const KEX_ALGORITHM_CURVE25519_SHA256 = 'curve25519-sha256'; +export const KEX_ALGORITHM_ECDH_NISTP256 = 'ecdh-sha2-nistp256'; + +export const SUPPORTED_KEX_ALGORITHMS = [ + KEX_ALGORITHM_CURVE25519_SHA256, + KEX_ALGORITHM_ECDH_NISTP256, +]; + +export const SUPPORTED_ENCRYPTION_ALGORITHMS = [ + 'aes128-gcm@openssh.com', + 'aes256-gcm@openssh.com', + 'aes128-ctr', + 'aes192-ctr', + 'aes256-ctr', +]; + +export const SUPPORTED_MAC_ALGORITHMS = [ + 'hmac-sha2-256', + 'hmac-sha2-512', +]; + +export interface CipherSpec { + mode: 'gcm' | 'ctr'; + blockSize: number; + ivLength: number; + keyLength: number; + aead: boolean; +} + +export interface MacSpec { + length: number; + keyLength: number; +} + +const CIPHER_SPECS: Record = { + 'aes256-gcm@openssh.com': { mode: 'gcm', blockSize: 16, ivLength: 12, keyLength: 32, aead: true }, + 'aes128-gcm@openssh.com': { mode: 'gcm', blockSize: 16, ivLength: 12, keyLength: 16, aead: true }, + 'aes256-ctr': { mode: 'ctr', blockSize: 16, ivLength: 16, keyLength: 32, aead: false }, + 'aes192-ctr': { mode: 'ctr', blockSize: 16, ivLength: 16, keyLength: 24, aead: false }, + 'aes128-ctr': { mode: 'ctr', blockSize: 16, ivLength: 16, keyLength: 16, aead: false }, +}; + +const MAC_SPECS: Record = { + none: { length: 0, keyLength: 32 }, + 'hmac-sha2-256': { length: 32, keyLength: 32 }, + 'hmac-sha2-512': { length: 64, keyLength: 64 }, +}; + +export function isCurve25519KEXAlgorithm(algorithm: string): boolean { + return algorithm === KEX_ALGORITHM_CURVE25519_SHA256; +} + +export function getCipherSpec(algorithm: string): CipherSpec { + const spec = CIPHER_SPECS[algorithm]; + if (!spec) throw new Error(`Unsupported cipher: ${algorithm}`); + return spec; +} + +export function getMacSpec(algorithm: string): MacSpec { + const spec = MAC_SPECS[algorithm]; + if (!spec) throw new Error(`Unsupported MAC algorithm: ${algorithm}`); + return spec; +} + +export function getMacAlgorithmsForCipher(cipher: string): string[] { + if (getCipherSpec(cipher).aead) return ['none']; + return SUPPORTED_MAC_ALGORITHMS; +} diff --git a/src/ssh/auth.ts b/src/ssh/auth.ts new file mode 100644 index 0000000..815ec53 --- /dev/null +++ b/src/ssh/auth.ts @@ -0,0 +1,832 @@ +import { SSH_MSG_USERAUTH_REQUEST } from '../types'; +import { encodeString, concat, readUint32, toBufferSource } from './utils'; + +// SSH key type constants +const SSH_ED25519 = 'ssh-ed25519'; +const SSH_RSA = 'ssh-rsa'; +const ECDSA_SHA2_NISTP256 = 'ecdsa-sha2-nistp256'; +const ECDSA_SHA2_NISTP384 = 'ecdsa-sha2-nistp384'; +const ECDSA_SHA2_NISTP521 = 'ecdsa-sha2-nistp521'; + +// Web Crypto algorithm names +const ED25519_ALGO = 'Ed25519'; +const RSA_ALGO = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }; +const ECDSA_P256_ALGO = { name: 'ECDSA', namedCurve: 'P-256' }; +const ECDSA_P384_ALGO = { name: 'ECDSA', namedCurve: 'P-384' }; +const ECDSA_P521_ALGO = { name: 'ECDSA', namedCurve: 'P-521' }; + +interface ParsedKey { + signingKey: CryptoKey; + rsaPkcs8?: Uint8Array; + publicKeyBlob: Uint8Array; + keyType: string; +} + +export class SSHAuth { + static buildPasswordAuthRequest( + username: string, + password: string + ): Uint8Array { + const parts: Uint8Array[] = [ + new Uint8Array([SSH_MSG_USERAUTH_REQUEST]), + encodeString(username), + encodeString('ssh-connection'), + encodeString('password'), + new Uint8Array([0x00]), + encodeString(password), + ]; + + return concat(...parts); + } + + /** Build a signed public-key authentication request (RFC 4252 section 7). */ + static async buildPublicKeyAuthRequest( + username: string, + privateKeyPEM: string, + sessionID: Uint8Array, + serverSigAlgs?: string[], + allowLegacyRsaSha1: boolean = false, + ): Promise { + const { signingKey, publicKeyBlob, keyType, rsaPkcs8 } = await this.parsePrivateKey(privateKeyPEM); + + let requestAlgo = keyType; + let signatureAlgo = keyType; + + if (keyType === SSH_RSA) { + const chosen = this.selectRsaSigAlgorithm(serverSigAlgs, allowLegacyRsaSha1); + requestAlgo = chosen; + signatureAlgo = chosen; + } + + // Build the request body (without signature first) + const requestBody = concat( + new Uint8Array([SSH_MSG_USERAUTH_REQUEST]), + encodeString(username), + encodeString('ssh-connection'), + encodeString('publickey'), + new Uint8Array([0x01]), // TRUE = has signature + encodeString(requestAlgo), + encodeString(publicKeyBlob), + ); + + // Data to sign: session_id_string || request_body + const dataToSign = concat(encodeString(sessionID), requestBody); + + // Sign based on key type + let rawSignature: Uint8Array; + let signatureBlob: Uint8Array; + + if (keyType === SSH_ED25519) { + rawSignature = new Uint8Array(await crypto.subtle.sign(ED25519_ALGO, signingKey, toBufferSource(dataToSign))); + signatureBlob = concat( + encodeString(SSH_ED25519), + encodeString(rawSignature), + ); + } else if (keyType === SSH_RSA) { + const hash = signatureAlgo === 'rsa-sha2-512' ? 'SHA-512' + : signatureAlgo === 'ssh-rsa' ? 'SHA-1' + : 'SHA-256'; + let sigKey = signingKey; + if (hash !== 'SHA-256' && rsaPkcs8) { + sigKey = await crypto.subtle.importKey( + 'pkcs8', toBufferSource(rsaPkcs8), + { name: 'RSASSA-PKCS1-v1_5', hash }, + false, ['sign'], + ); + } + rawSignature = new Uint8Array( + await crypto.subtle.sign({ name: 'RSASSA-PKCS1-v1_5', hash }, sigKey, toBufferSource(dataToSign)) + ); + signatureBlob = concat( + encodeString(signatureAlgo), + encodeString(rawSignature), + ); + } else if (keyType.startsWith('ecdsa-sha2-')) { + const hash = this.ecdsaHashForCurve(keyType); + const sigBytes = new Uint8Array(await crypto.subtle.sign( + { name: 'ECDSA', hash }, + signingKey, + toBufferSource(dataToSign) + )); + const sshSignature = this.ecdsaWebCryptoToSSH(sigBytes, this.ecdsaCoordBytes(keyType)); + signatureBlob = concat( + encodeString(keyType), + encodeString(sshSignature), + ); + } else { + throw new Error(`Unsupported key type: ${keyType}`); + } + + // Full auth packet: requestBody || string signature_blob + return concat(requestBody, encodeString(signatureBlob)); + } + private static selectRsaSigAlgorithm( + serverSigAlgs: string[] | undefined, + allowLegacyRsaSha1: boolean, + ): string { + const localOrder = allowLegacyRsaSha1 + ? ['rsa-sha2-512', 'rsa-sha2-256', 'ssh-rsa'] + : ['rsa-sha2-512', 'rsa-sha2-256']; + + if (!serverSigAlgs || serverSigAlgs.length === 0) { + return 'rsa-sha2-256'; + } + + const serverSet = new Set(serverSigAlgs); + for (const algo of localOrder) { + if (serverSet.has(algo)) return algo; + } + throw new Error( + `no_supported_rsa_signature_algorithm: server=[${serverSigAlgs.join(',')}] local=[${localOrder.join(',')}]` + ); + } + private static ecdsaHashForCurve(keyType: string): 'SHA-256' | 'SHA-384' | 'SHA-512' { + switch (keyType) { + case ECDSA_SHA2_NISTP256: return 'SHA-256'; + case ECDSA_SHA2_NISTP384: return 'SHA-384'; + case ECDSA_SHA2_NISTP521: return 'SHA-512'; + } + throw new Error(`unsupported ECDSA key type: ${keyType}`); + } + private static ecdsaCoordBytes(keyType: string): number { + switch (keyType) { + case ECDSA_SHA2_NISTP256: return 32; + case ECDSA_SHA2_NISTP384: return 48; + case ECDSA_SHA2_NISTP521: return 66; + } + throw new Error(`unsupported ECDSA key type: ${keyType}`); + } + private static ecdsaWebCryptoToSSH(sigBytes: Uint8Array, coordBytes: number): Uint8Array { + if (sigBytes.length < 2) throw new Error('ECDSA signature is too short'); + + // Workers Web Crypto returns IEEE-P1363 r || s. Check its exact size + // before considering DER because a valid r can itself start with 0x30. + if (sigBytes.length === coordBytes * 2) { + const r = sigBytes.subarray(0, coordBytes); + const s = sigBytes.subarray(coordBytes); + return concat(this.sshMPInt(r), this.sshMPInt(s)); + } + if (sigBytes[0] === 0x30) return this.convertECDSADERToSSH(sigBytes); + throw new Error(`Invalid ECDSA signature length: expected ${coordBytes * 2}; received ${sigBytes.length}`); + } + + /** + * Parse an OpenSSH private key and detect its type. + */ + private static async parsePrivateKey(pem: string): Promise { + const match = /^-----BEGIN OPENSSH PRIVATE KEY-----\r?\n([A-Za-z0-9+/=\r\n]+)\r?\n-----END OPENSSH PRIVATE KEY-----$/.exec(pem.trim()); + if (!match) throw new Error('Unsupported private key format; only OpenSSH keys are accepted'); + const b64 = match[1].replace(/\r?\n/g, ''); + if (b64.length === 0 || b64.length % 4 !== 0) throw new Error('Malformed OpenSSH private key Base64 data'); + let raw: Uint8Array; + try { + const binary = atob(b64); + raw = Uint8Array.from(binary, c => c.charCodeAt(0)); + const canonical = btoa(binary); + if (canonical !== b64) throw new Error('non-canonical Base64'); + } catch { + throw new Error('Malformed OpenSSH private key Base64 data'); + } + + // Parse OpenSSH format: "openssh-key-v1\0" magic + const magic = 'openssh-key-v1\0'; + const magicBytes = new TextEncoder().encode(magic); + if (raw.length < magicBytes.length) { + throw new Error('Private key data is too short'); + } + for (let i = 0; i < magicBytes.length; i++) { + if (raw[i] !== magicBytes[i]) { + throw new Error('Unsupported private key format; only OpenSSH keys are accepted'); + } + } + let offset = magicBytes.length; + + // ciphername + if (offset + 4 > raw.length) throw new Error('Malformed private key cipher length'); + const cipherLen = readUint32(raw, offset); offset += 4; + if (offset + cipherLen > raw.length) throw new Error('Malformed private key cipher field'); + const cipher = this.decodeText(raw.slice(offset, offset + cipherLen), 'private key cipher'); offset += cipherLen; + if (cipher !== 'none') throw new Error('Encrypted private keys are not supported; remove the passphrase with ssh-keygen -p'); + + // kdfname + if (offset + 4 > raw.length) throw new Error('Malformed private key KDF length'); + const kdfLen = readUint32(raw, offset); offset += 4; + if (offset + kdfLen > raw.length) throw new Error('Malformed private key KDF field'); + const kdf = this.decodeText(raw.slice(offset, offset + kdfLen), 'private key KDF'); + offset += kdfLen; + if (kdf !== 'none') { + throw new Error('Malformed unencrypted private key: KDF must be none'); + } + + // kdfoptions + if (offset + 4 > raw.length) throw new Error('Malformed private key KDF options length'); + const kdfOptLen = readUint32(raw, offset); offset += 4; + if (offset + kdfOptLen > raw.length) throw new Error('Malformed private key KDF options'); + if (kdfOptLen !== 0) { + throw new Error('Malformed unencrypted private key: KDF options must be empty'); + } + offset += kdfOptLen; + + // number of keys + if (offset + 4 > raw.length) throw new Error('Malformed private key count'); + const numKeys = readUint32(raw, offset); offset += 4; + if (numKeys !== 1) throw new Error('Only single-key files are supported'); + + // public key section + if (offset + 4 > raw.length) throw new Error('Malformed public key section length'); + const pubSecLen = readUint32(raw, offset); offset += 4; + if (offset + pubSecLen > raw.length) throw new Error('Malformed public key section'); + const publicKeyBlob = raw.slice(offset, offset + pubSecLen); + offset += pubSecLen; + + // private key section + if (offset + 4 > raw.length) throw new Error('Malformed private key section length'); + const privSecLen = readUint32(raw, offset); offset += 4; + if (offset + privSecLen > raw.length) throw new Error('Malformed private key section'); + const privSection = raw.slice(offset, offset + privSecLen); + offset += privSecLen; + if (offset !== raw.length) throw new Error('Trailing data after private key section'); + + // Parse private section: checkint1, checkint2, keytype, ... + let po = 0; + if (privSection.length < 8) throw new Error('Malformed private key check integers'); + const checkint1 = readUint32(privSection, po); po += 4; + const checkint2 = readUint32(privSection, po); po += 4; + if (checkint1 !== checkint2) { + throw new Error('Private key check integers do not match'); + } + + // key type + if (po + 4 > privSection.length) throw new Error('Malformed private key type length'); + const ktLen = readUint32(privSection, po); po += 4; + if (po + ktLen > privSection.length) throw new Error('Malformed private key type'); + const keyType = this.decodeText(privSection.slice(po, po + ktLen), 'private key type'); po += ktLen; + + // Parse based on key type + if (keyType === SSH_ED25519) { + return this.parseEd25519Key(privSection, po, publicKeyBlob); + } else if (keyType === SSH_RSA) { + return this.parseRSAKey(privSection, po, publicKeyBlob); + } else if (keyType.startsWith('ecdsa-sha2-')) { + return this.parseECDSAKey(privSection, po, keyType, publicKeyBlob); + } else { + throw new Error(`Unsupported key type: ${keyType}`); + } + } + + /** + * Parse Ed25519 private key from OpenSSH format. + */ + private static async parseEd25519Key( + privSection: Uint8Array, + offset: number, + outerPublicKeyBlob: Uint8Array, + ): Promise { + let po = offset; + + // public key (32 bytes) + if (po + 4 > privSection.length) throw new Error('Malformed public key length'); + const pubKeyLen = readUint32(privSection, po); po += 4; + if (po + pubKeyLen > privSection.length) throw new Error('Malformed public key'); + const pubKeyRaw = privSection.slice(po, po + pubKeyLen); po += pubKeyLen; + if (pubKeyRaw.length !== 32) throw new Error('Malformed Ed25519 public key length'); + + // private key (64 bytes = 32 bytes seed + 32 bytes pubkey) + if (po + 4 > privSection.length) throw new Error('Malformed private key length'); + const privKeyLen = readUint32(privSection, po); po += 4; + if (po + privKeyLen > privSection.length) throw new Error('Malformed private key'); + const privKeyRaw = privSection.slice(po, po + privKeyLen); po += privKeyLen; + if (privKeyRaw.length !== 64) throw new Error('Malformed Ed25519 private key length'); + if (!this.bytesEqual(pubKeyRaw, privKeyRaw.subarray(32))) { + throw new Error('Ed25519 private key public suffix does not match its public key'); + } + + this.validateEd25519PublicBlob(outerPublicKeyBlob, pubKeyRaw); + this.validatePrivateSectionTail(privSection, po); + + const seed = privKeyRaw.slice(0, 32); + + const pkcs8 = this.buildEd25519PKCS8(seed); + const signingKey = await crypto.subtle.importKey( + 'pkcs8', toBufferSource(pkcs8), { name: ED25519_ALGO }, false, ['sign'] + ); + const probe = new Uint8Array([0x43, 0x46, 0x57, 0x53]); + const probeSignature = await crypto.subtle.sign(ED25519_ALGO, signingKey, toBufferSource(probe)); + const publicKey = await crypto.subtle.importKey('raw', toBufferSource(pubKeyRaw), { name: ED25519_ALGO }, false, ['verify']); + if (!await crypto.subtle.verify(ED25519_ALGO, publicKey, probeSignature, toBufferSource(probe))) { + throw new Error('Ed25519 private seed does not match its public key'); + } + + return { signingKey, publicKeyBlob: outerPublicKeyBlob, keyType: SSH_ED25519 }; + } + + /** + * Parse RSA private key from OpenSSH format. + */ + private static async parseRSAKey( + privSection: Uint8Array, + offset: number, + outerPublicKeyBlob: Uint8Array, + ): Promise { + let po = offset; + + const readMPINT = (name: string): Uint8Array => { + const field = this.readPositiveMPInt(privSection, po, `RSA ${name}`); + po = field.offset; + return field.value; + }; + + const n = readMPINT('modulus'); + const e = readMPINT('public exponent'); + const d = readMPINT('private exponent'); + const iqmp = readMPINT('coefficient'); + const p = readMPINT('prime p'); + const q = readMPINT('prime q'); + + this.validateRSAPublicBlob(outerPublicKeyBlob, e, n); + this.validateRSAComponents(n, e, d, iqmp, p, q); + this.validatePrivateSectionTail(privSection, po); + + const pkcs8 = this.buildRSAPKCS8(n, e, d, p, q, iqmp); + + const signingKey = await crypto.subtle.importKey( + 'pkcs8', toBufferSource(pkcs8), RSA_ALGO, false, ['sign'] + ); + + return { signingKey, rsaPkcs8: pkcs8, publicKeyBlob: outerPublicKeyBlob, keyType: SSH_RSA }; + } + + /** + * Parse ECDSA private key from OpenSSH format. + */ + private static async parseECDSAKey( + privSection: Uint8Array, + offset: number, + keyType: string, + outerPublicKeyBlob: Uint8Array, + ): Promise { + let po = offset; + + let namedCurve: string; + let algo: EcKeyImportParams; + + if (keyType === ECDSA_SHA2_NISTP256) { + namedCurve = 'P-256'; + algo = ECDSA_P256_ALGO; + } else if (keyType === ECDSA_SHA2_NISTP384) { + namedCurve = 'P-384'; + algo = ECDSA_P384_ALGO; + } else if (keyType === ECDSA_SHA2_NISTP521) { + namedCurve = 'P-521'; + algo = ECDSA_P521_ALGO; + } else { + throw new Error(`Unsupported ECDSA curve: ${keyType}`); + } + + // curve name + if (po + 4 > privSection.length) throw new Error('Malformed private key curve length'); + const curveLen = readUint32(privSection, po); po += 4; + if (po + curveLen > privSection.length) throw new Error('Malformed private key curve'); + const curve = this.decodeText(privSection.slice(po, po + curveLen), 'private key curve'); po += curveLen; + + const expectedCurve = namedCurve.replace('P-', 'nistp'); + if (curve !== expectedCurve) { + throw new Error(`Curve mismatch: expected ${expectedCurve}; received ${curve}`); + } + + // public key + if (po + 4 > privSection.length) throw new Error('Malformed public key length'); + const pubKeyLen = readUint32(privSection, po); po += 4; + if (po + pubKeyLen > privSection.length) throw new Error('Malformed public key'); + const pubKeyRaw = privSection.slice(po, po + pubKeyLen); po += pubKeyLen; + + // private key + if (po + 4 > privSection.length) throw new Error('Malformed private key length'); + const scalar = this.readPositiveMPInt(privSection, po, 'ECDSA private scalar'); + po = scalar.offset; + + const coordBytes = this.ecdsaCoordBytes(keyType); + if (scalar.value.length > coordBytes) throw new Error('Malformed ECDSA private scalar length'); + const privKeyRaw = new Uint8Array(coordBytes); + privKeyRaw.set(scalar.value, coordBytes - scalar.value.length); + + if (pubKeyRaw.length !== 1 + (coordBytes * 2) || pubKeyRaw[0] !== 0x04) { + throw new Error('Malformed ECDSA public key point'); + } + this.validateECDSAPublicBlob(outerPublicKeyBlob, keyType, curve, pubKeyRaw); + this.validatePrivateSectionTail(privSection, po); + + const pkcs8 = this.buildECDSAPKCS8(namedCurve, privKeyRaw); + + const signingKey = await crypto.subtle.importKey( + 'pkcs8', toBufferSource(pkcs8), algo, false, ['sign'] + ); + const publicKey = await crypto.subtle.importKey('raw', toBufferSource(pubKeyRaw), algo, false, ['verify']); + const probe = new Uint8Array([0x43, 0x46, 0x57, 0x53]); + const probeSignature = await crypto.subtle.sign( + { name: 'ECDSA', hash: this.ecdsaHashForCurve(keyType) }, + signingKey, + toBufferSource(probe), + ); + if (!await crypto.subtle.verify( + { name: 'ECDSA', hash: this.ecdsaHashForCurve(keyType) }, + publicKey, + probeSignature, + toBufferSource(probe), + )) throw new Error('ECDSA private scalar does not match its public key'); + + return { signingKey, publicKeyBlob: outerPublicKeyBlob, keyType }; + } + + private static validateEd25519PublicBlob(blob: Uint8Array, publicKey: Uint8Array): void { + let offset = 0; + const type = this.readSSHString(blob, offset, 'Ed25519 public key type'); offset = type.offset; + const key = this.readSSHString(blob, offset, 'Ed25519 public key'); offset = key.offset; + if (this.decodeText(type.value, 'Ed25519 public key type') !== SSH_ED25519) { + throw new Error('Ed25519 outer public key type does not match private key'); + } + if (offset !== blob.length) throw new Error('Trailing data in Ed25519 public key blob'); + if (key.value.length !== 32 || !this.bytesEqual(key.value, publicKey)) { + throw new Error('Ed25519 outer public key does not match private key'); + } + } + + private static validateRSAPublicBlob(blob: Uint8Array, exponent: Uint8Array, modulus: Uint8Array): void { + let offset = 0; + const type = this.readSSHString(blob, offset, 'RSA public key type'); offset = type.offset; + if (this.decodeText(type.value, 'RSA public key type') !== SSH_RSA) { + throw new Error('RSA outer public key type does not match private key'); + } + const outerExponent = this.readPositiveMPInt(blob, offset, 'RSA outer public exponent'); + offset = outerExponent.offset; + const outerModulus = this.readPositiveMPInt(blob, offset, 'RSA outer modulus'); + offset = outerModulus.offset; + if (offset !== blob.length) throw new Error('Trailing data in RSA public key blob'); + if (!this.bytesEqual(outerExponent.value, exponent) || !this.bytesEqual(outerModulus.value, modulus)) { + throw new Error('RSA outer public key does not match private key'); + } + } + + private static validateECDSAPublicBlob( + blob: Uint8Array, + keyType: string, + curve: string, + publicKey: Uint8Array, + ): void { + let offset = 0; + const outerType = this.readSSHString(blob, offset, 'ECDSA public key type'); offset = outerType.offset; + const outerCurve = this.readSSHString(blob, offset, 'ECDSA public key curve'); offset = outerCurve.offset; + const outerKey = this.readSSHString(blob, offset, 'ECDSA public key point'); offset = outerKey.offset; + if (offset !== blob.length) throw new Error('Trailing data in ECDSA public key blob'); + if (this.decodeText(outerType.value, 'ECDSA public key type') !== keyType + || this.decodeText(outerCurve.value, 'ECDSA public key curve') !== curve + || !this.bytesEqual(outerKey.value, publicKey)) { + throw new Error('ECDSA outer public key does not match private key'); + } + } + + private static validatePrivateSectionTail(section: Uint8Array, offset: number): void { + const comment = this.readSSHString(section, offset, 'private key comment'); + const paddingLength = section.length - comment.offset; + if (section.length % 8 !== 0 || paddingLength > 7) { + throw new Error('Malformed OpenSSH private key padding'); + } + for (let i = 0; i < paddingLength; i++) { + if (section[comment.offset + i] !== i + 1) { + throw new Error('Malformed OpenSSH private key padding'); + } + } + } + + private static readSSHString( + data: Uint8Array, + offset: number, + label: string, + ): { value: Uint8Array; offset: number } { + if (offset + 4 > data.length) throw new Error(`Malformed ${label} length`); + const length = readUint32(data, offset); + offset += 4; + if (offset + length > data.length) throw new Error(`Malformed ${label} data`); + return { value: data.slice(offset, offset + length), offset: offset + length }; + } + + private static readPositiveMPInt( + data: Uint8Array, + offset: number, + label: string, + ): { value: Uint8Array; offset: number } { + const field = this.readSSHString(data, offset, label); + const value = field.value; + if (value.length === 0 || (value[0] & 0x80) !== 0) { + throw new Error(`Malformed ${label}: expected a positive MPINT`); + } + if (value[0] === 0) { + if (value.length === 1 || (value[1] & 0x80) === 0) { + throw new Error(`Malformed ${label}: non-canonical MPINT`); + } + return { value: value.slice(1), offset: field.offset }; + } + return field; + } + + private static decodeText(data: Uint8Array, label: string): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(data); + } catch { + throw new Error(`Malformed ${label}: invalid UTF-8`); + } + } + + private static bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let difference = 0; + for (let i = 0; i < a.length; i++) difference |= a[i] ^ b[i]; + return difference === 0; + } + + private static validateRSAComponents( + n: Uint8Array, + e: Uint8Array, + d: Uint8Array, + iqmp: Uint8Array, + p: Uint8Array, + q: Uint8Array, + ): void { + const toBigInt = (bytes: Uint8Array): bigint => { + let value = 0n; + for (const byte of bytes) value = (value << 8n) | BigInt(byte); + return value; + }; + const modulus = toBigInt(n); + const exponent = toBigInt(e); + const privateExponent = toBigInt(d); + const primeP = toBigInt(p); + const primeQ = toBigInt(q); + const coefficient = toBigInt(iqmp); + if (primeP <= 2n || primeQ <= 2n || modulus !== primeP * primeQ) throw new Error('RSA private factors do not match the modulus'); + if (exponent < 3n || (exponent & 1n) === 0n || privateExponent < 1n) throw new Error('Malformed RSA exponents'); + if ((coefficient * primeQ) % primeP !== 1n) throw new Error('RSA CRT coefficient does not match the private factors'); + const pMinus1 = primeP - 1n; + const qMinus1 = primeQ - 1n; + if ((privateExponent * exponent) % pMinus1 !== 1n || (privateExponent * exponent) % qMinus1 !== 1n) { + throw new Error('RSA private exponent does not match the public exponent'); + } + } + + /** + * Build PKCS#8 DER format for Ed25519 seed. + */ + private static buildEd25519PKCS8(seed: Uint8Array): Uint8Array { + const oid = new Uint8Array([0x06, 0x03, 0x2b, 0x65, 0x70]); + const seedOctet = new Uint8Array([0x04, seed.length, ...seed]); + const innerOctet = new Uint8Array([0x04, seedOctet.length, ...seedOctet]); + const algoSeq = new Uint8Array([0x30, oid.length, ...oid]); + const version = new Uint8Array([0x02, 0x01, 0x00]); + const totalLen = version.length + algoSeq.length + innerOctet.length; + return new Uint8Array([0x30, totalLen, ...version, ...algoSeq, ...innerOctet]); + } + + /** + * Build PKCS#8 DER format for RSA private key. + */ + private static buildRSAPKCS8( + n: Uint8Array, e: Uint8Array, d: Uint8Array, + p: Uint8Array, q: Uint8Array, iqmp: Uint8Array + ): Uint8Array { + const pkcs1 = this.buildRSAPKCS1(n, e, d, p, q, iqmp); + + const rsaOid = new Uint8Array([0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01]); + const nullParam = new Uint8Array([0x05, 0x00]); + const algoSeq = this.buildDERSequence(concat(rsaOid, nullParam)); + + const version = new Uint8Array([0x02, 0x01, 0x00]); + const privKeyOctet = this.buildDEROctetString(pkcs1); + + return this.buildDERSequence(concat(version, algoSeq, privKeyOctet)); + } + + /** + * Build PKCS#1 RSAPrivateKey DER format. + */ + private static buildRSAPKCS1( + n: Uint8Array, e: Uint8Array, d: Uint8Array, + p: Uint8Array, q: Uint8Array, iqmp: Uint8Array + ): Uint8Array { + const version = this.buildDERInteger(new Uint8Array([0])); + const modulus = this.buildDERInteger(n); + const publicExp = this.buildDERInteger(e); + const privateExp = this.buildDERInteger(d); + const prime1 = this.buildDERInteger(p); + const prime2 = this.buildDERInteger(q); + + const pMinus1 = this.bigIntSubtract(p, new Uint8Array([1])); + const qMinus1 = this.bigIntSubtract(q, new Uint8Array([1])); + const exponent1 = this.buildDERInteger(this.bigIntMod(d, pMinus1)); + const exponent2 = this.buildDERInteger(this.bigIntMod(d, qMinus1)); + const coefficient = this.buildDERInteger(iqmp); + + return this.buildDERSequence( + concat(version, modulus, publicExp, privateExp, prime1, prime2, exponent1, exponent2, coefficient) + ); + } + + /** + * Build PKCS#8 DER format for ECDSA private key. + */ + private static buildECDSAPKCS8(namedCurve: string, privateKey: Uint8Array): Uint8Array { + let curveOid: Uint8Array; + if (namedCurve === 'P-256') { + curveOid = new Uint8Array([0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07]); + } else if (namedCurve === 'P-384') { + curveOid = new Uint8Array([0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x22]); + } else if (namedCurve === 'P-521') { + curveOid = new Uint8Array([0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x23]); + } else { + throw new Error(`Unsupported curve: ${namedCurve}`); + } + + const ecVersion = this.buildDERInteger(new Uint8Array([1])); + const ecPrivKeyOctet = this.buildDEROctetString(privateKey); + const parameters = new Uint8Array([0xa0, curveOid.length, ...curveOid]); + const ecPrivateKey = this.buildDERSequence(concat(ecVersion, ecPrivKeyOctet, parameters)); + + const ecOid = new Uint8Array([0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01]); + const algoSeq = this.buildDERSequence(concat(ecOid, curveOid)); + + const pkcs8Version = this.buildDERInteger(new Uint8Array([0])); + const privateKeyOctet = this.buildDEROctetString(ecPrivateKey); + + return this.buildDERSequence(concat(pkcs8Version, algoSeq, privateKeyOctet)); + } + + /** + * Build DER INTEGER. + */ + private static buildDERInteger(value: Uint8Array): Uint8Array { + let data = value; + if (data.length > 0 && (data[0] & 0x80) !== 0) { + data = concat(new Uint8Array([0]), data); + } + while (data.length > 1 && data[0] === 0 && data[1] === 0) { + data = data.slice(1); + } + + return concat( + new Uint8Array([0x02]), + this.encodeDERLength(data.length), + data + ); + } + + /** + * Build DER OCTET STRING. + */ + private static buildDEROctetString(data: Uint8Array): Uint8Array { + return concat( + new Uint8Array([0x04]), + this.encodeDERLength(data.length), + data + ); + } + + /** + * Build DER SEQUENCE. + */ + private static buildDERSequence(data: Uint8Array): Uint8Array { + return concat( + new Uint8Array([0x30]), + this.encodeDERLength(data.length), + data + ); + } + + /** + * Encode DER length. + */ + private static encodeDERLength(length: number): Uint8Array { + if (length < 0x80) { + return new Uint8Array([length]); + } else if (length < 0x100) { + return new Uint8Array([0x81, length]); + } else if (length < 0x10000) { + return new Uint8Array([0x82, (length >> 8) & 0xff, length & 0xff]); + } else { + throw new Error('DER length is out of range'); + } + } + + /** + * Convert ECDSA DER signature to SSH format (r || s). + */ + private static convertECDSADERToSSH(derSignature: Uint8Array): Uint8Array { + let offset = 0; + + if (derSignature.length < 8 || derSignature[offset] !== 0x30) throw new Error('Invalid DER signature'); + offset++; + + const sequence = this.readDERLength(derSignature, offset); + offset = sequence.offset; + if (sequence.length !== derSignature.length - offset) throw new Error('Invalid DER signature length'); + + if (derSignature[offset] !== 0x02) throw new Error('Invalid DER signature'); + offset++; + + const rLength = this.readDERLength(derSignature, offset); + offset = rLength.offset; + if (rLength.length === 0 || rLength.length > derSignature.length - offset) throw new Error('Invalid DER signature integer'); + const r = derSignature.slice(offset, offset + rLength.length); + offset += rLength.length; + + if (derSignature[offset] !== 0x02) throw new Error('Invalid DER signature'); + offset++; + + const sLength = this.readDERLength(derSignature, offset); + offset = sLength.offset; + if (sLength.length === 0 || sLength.length !== derSignature.length - offset) throw new Error('Invalid DER signature integer'); + const s = derSignature.slice(offset, offset + sLength.length); + + for (const integer of [r, s]) { + if ((integer[0] & 0x80) !== 0 || (integer.length > 1 && integer[0] === 0 && (integer[1] & 0x80) === 0)) { + throw new Error('Invalid DER signature integer encoding'); + } + } + + return concat(this.sshMPInt(r), this.sshMPInt(s)); + } + + private static readDERLength(data: Uint8Array, offset: number): { length: number; offset: number } { + if (offset >= data.length) throw new Error('Invalid DER length'); + const first = data[offset++]; + if (first < 0x80) return { length: first, offset }; + const bytes = first & 0x7f; + if (bytes === 0 || bytes > 4 || offset + bytes > data.length || data[offset] === 0) throw new Error('Invalid DER length'); + let length = 0; + for (let index = 0; index < bytes; index++) length = (length * 256) + data[offset + index]; + if (length < 0x80 || !Number.isSafeInteger(length)) throw new Error('Invalid DER length'); + return { length, offset: offset + bytes }; + } + + /** + * Encode MPINT in SSH format. + */ + private static sshMPInt(value: Uint8Array): Uint8Array { + let start = 0; + while (start < value.length - 1 && value[start] === 0) { + start++; + } + const significant = value.subarray(start); + + const needsLeadingZero = significant.length > 0 && (significant[0] & 0x80) !== 0; + const data = needsLeadingZero + ? concat(new Uint8Array([0]), significant) + : significant; + + return encodeString(data); + } + + /** + * Subtract two big integers (big-endian). + */ + private static bigIntSubtract(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length); + let borrow = 0; + + for (let i = a.length - 1; i >= 0; i--) { + const aByte = a[i]; + const bByte = i >= a.length - b.length ? b[b.length - (a.length - i)] : 0; + + let diff = aByte - bByte - borrow; + if (diff < 0) { + diff += 256; + borrow = 1; + } else { + borrow = 0; + } + result[i] = diff; + } + + let start = 0; + while (start < result.length - 1 && result[start] === 0) { + start++; + } + return result.slice(start); + } + + /** + * Calculate a mod m for big integers. + */ + private static bigIntMod(a: Uint8Array, m: Uint8Array): Uint8Array { + const toBigInt = (bytes: Uint8Array): bigint => { + let n = 0n; + for (const b of bytes) n = (n << 8n) | BigInt(b); + return n; + }; + const r = toBigInt(a) % toBigInt(m); + const hex = r.toString(16).padStart(2, '0'); + const padded = hex.length % 2 ? '0' + hex : hex; + const out = new Uint8Array(padded.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(padded.slice(i * 2, i * 2 + 2), 16); + return out; + } + +} diff --git a/src/ssh/channel.ts b/src/ssh/channel.ts new file mode 100644 index 0000000..448f4e5 --- /dev/null +++ b/src/ssh/channel.ts @@ -0,0 +1,325 @@ +import { + SSH_MSG_CHANNEL_OPEN, + SSH_MSG_CHANNEL_WINDOW_ADJUST, + SSH_MSG_CHANNEL_DATA, + SSH_MSG_CHANNEL_REQUEST, + SSH_MSG_CHANNEL_EOF, + SSH_MSG_CHANNEL_CLOSE, +} from '../types'; +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 EXEC_FIELD = encodeString('exec'); +const WINDOW_CHANGE_FIELD = encodeString('window-change'); +const EMPTY_TERMINAL_MODES_FIELD = encodeString(new Uint8Array([0])); +const UINT32_MAX = 0xffffffff; +const DEFAULT_WINDOW_SIZE = 2 * 1024 * 1024; +const DEFAULT_MAX_PACKET_SIZE = 32 * 1024; + +export interface ChannelDataChunk { + source: Uint8Array; + sourceOffset: number; + bytesConsumed: number; + payloadLength: number; +} + +function writeBytes(target: Uint8Array, offset: number, source: Uint8Array): number { + target.set(source, offset); + return offset + source.length; +} + +export class SSHChannel { + private localChannelID: number = 0; + private remoteChannelID: number = 0; + private localWindowSize: number = DEFAULT_WINDOW_SIZE; + private remoteWindowSize: number = 0; + private remoteMaxPacketSize: number = DEFAULT_MAX_PACKET_SIZE; + private consumedSinceWindowAdjust: number = 0; + private eofSent: boolean = false; + private eofReceived: boolean = false; + private closeSent: boolean = false; + private closeReceived: boolean = false; + private openConfirmed: boolean = false; + + getLocalChannelID(): number { + return this.localChannelID; + } + + getRemoteChannelID(): number { + return this.remoteChannelID; + } + + isClosed(): boolean { + return this.closeSent && this.closeReceived; + } + + hasSentClose(): boolean { + return this.closeSent; + } + + buildOpenSession(channelID: number = 0): Uint8Array { + this.localChannelID = channelID; + + const payload = new Uint8Array(1 + SESSION_FIELD.length + 12); + let offset = 0; + payload[offset++] = SSH_MSG_CHANNEL_OPEN; + offset = writeBytes(payload, offset, SESSION_FIELD); + writeUint32(payload, offset, this.localChannelID); + offset += 4; + writeUint32(payload, offset, DEFAULT_WINDOW_SIZE); + offset += 4; + writeUint32(payload, offset, DEFAULT_MAX_PACKET_SIZE); + return payload; + } + + handleOpenConfirmation(payload: Uint8Array): void { + if (payload.length !== 17) throw new Error('Malformed channel open confirmation'); + if (readUint32(payload, 1) !== this.localChannelID) throw new Error('Channel confirmation has an unexpected recipient'); + if (this.openConfirmed) throw new Error('Duplicate channel open confirmation'); + let offset = 1; + offset += 4; + this.remoteChannelID = readUint32(payload, offset); + offset += 4; + this.remoteWindowSize = readUint32(payload, offset); + offset += 4; + const serverMaxPacket = readUint32(payload, offset); + if (serverMaxPacket === 0) throw new Error('Channel maximum packet size must be positive'); + this.remoteMaxPacketSize = serverMaxPacket; + this.openConfirmed = true; + } + + handleOpenFailure(payload: Uint8Array): void { + if (payload.length < 17 || readUint32(payload, 1) !== this.localChannelID) { + throw new Error('Malformed channel open failure'); + } + let offset = 9; + for (let index = 0; index < 2; index++) { + if (offset + 4 > payload.length) throw new Error('Malformed channel open failure'); + const length = readUint32(payload, offset); + offset += 4; + if (length > payload.length - offset) throw new Error('Malformed channel open failure'); + offset += length; + } + if (offset !== payload.length) throw new Error('Malformed channel open failure'); + } + + buildPTYRequest(cols: number, rows: number, terminal = 'xterm-256color'): Uint8Array { + const terminalField = encodeString(terminal); + const payload = new Uint8Array( + 1 + 4 + PTY_REQ_FIELD.length + 1 + terminalField.length + 16 + EMPTY_TERMINAL_MODES_FIELD.length + ); + let offset = 0; + payload[offset++] = SSH_MSG_CHANNEL_REQUEST; + writeUint32(payload, offset, this.remoteChannelID); + offset += 4; + offset = writeBytes(payload, offset, PTY_REQ_FIELD); + payload[offset++] = 0x01; + offset = writeBytes(payload, offset, terminalField); + writeUint32(payload, offset, cols); + offset += 4; + writeUint32(payload, offset, rows); + offset += 4; + writeUint32(payload, offset, 0); + offset += 4; + writeUint32(payload, offset, 0); + offset += 4; + writeBytes(payload, offset, EMPTY_TERMINAL_MODES_FIELD); + return payload; + } + + buildShellRequest(): Uint8Array { + const payload = new Uint8Array(1 + 4 + SHELL_FIELD.length + 1); + let offset = 0; + payload[offset++] = SSH_MSG_CHANNEL_REQUEST; + writeUint32(payload, offset, this.remoteChannelID); + offset += 4; + offset = writeBytes(payload, offset, SHELL_FIELD); + payload[offset] = 0x01; + return payload; + } + + buildSubsystemRequest(subsystem: string): Uint8Array { + const name = encodeString(subsystem); + const payload = new Uint8Array(1 + 4 + SUBSYSTEM_FIELD.length + 1 + name.length); + let offset = 0; + payload[offset++] = SSH_MSG_CHANNEL_REQUEST; + writeUint32(payload, offset, this.remoteChannelID); + offset += 4; + offset = writeBytes(payload, offset, SUBSYSTEM_FIELD); + payload[offset++] = 0x01; // want_reply = true + writeBytes(payload, offset, name); + return payload; + } + + buildExecRequest(command: string): Uint8Array { + const cmdBytes = encodeString(command); + const payload = new Uint8Array(1 + 4 + EXEC_FIELD.length + 1 + cmdBytes.length); + let offset = 0; + payload[offset++] = SSH_MSG_CHANNEL_REQUEST; + writeUint32(payload, offset, this.remoteChannelID); + offset += 4; + offset = writeBytes(payload, offset, EXEC_FIELD); + payload[offset++] = 0x01; // want_reply = true + writeBytes(payload, offset, cmdBytes); + return payload; + } + + buildEof(): Uint8Array { + if (this.eofSent || this.closeSent) 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.remoteChannelID); + return payload; + } + + buildClose(): Uint8Array { + if (this.closeSent) throw new Error('Channel close was already sent'); + this.closeSent = true; + const payload = new Uint8Array(5); + payload[0] = SSH_MSG_CHANNEL_CLOSE; + writeUint32(payload, 1, this.remoteChannelID); + return payload; + } + + takeChannelDataChunk(data: Uint8Array, offset: number = 0): ChannelDataChunk | null { + const bytesAvailable = data.length - offset; + if (bytesAvailable <= 0) { + return null; + } + + const bytesToSend = Math.min(bytesAvailable, this.remoteMaxPacketSize, this.remoteWindowSize); + if (bytesToSend <= 0) { + return null; + } + + this.remoteWindowSize -= bytesToSend; + return { + source: data, + sourceOffset: offset, + bytesConsumed: bytesToSend, + payloadLength: 9 + bytesToSend, + }; + } + + writeChannelDataPayload( + target: Uint8Array, + offset: number, + source: Uint8Array, + sourceOffset: number, + sourceLength: number + ): void { + target[offset] = SSH_MSG_CHANNEL_DATA; + writeUint32(target, offset + 1, this.remoteChannelID); + writeUint32(target, offset + 5, sourceLength); + target.set(source.subarray(sourceOffset, sourceOffset + sourceLength), offset + 9); + } + + handleWindowAdjust(payload: Uint8Array): number { + if (payload.length !== 9) throw new Error('Malformed channel window adjustment'); + const recipientChannelID = readUint32(payload, 1); + if (recipientChannelID !== this.localChannelID) { + throw new Error('Channel window adjustment has an unexpected recipient'); + } + + const bytesToAdd = readUint32(payload, 5); + if (bytesToAdd === 0 || bytesToAdd > UINT32_MAX - this.remoteWindowSize) { + throw new Error('Invalid channel window adjustment'); + } + this.remoteWindowSize += bytesToAdd; + return bytesToAdd; + } + + handleChannelData(payload: Uint8Array): Uint8Array { + if (payload.length < 9) throw new Error('Malformed channel data'); + let offset = 1; + const recipientChannelID = readUint32(payload, offset); + if (recipientChannelID !== this.localChannelID) throw new Error('Channel data has an unexpected recipient'); + offset += 4; + const dataLen = readUint32(payload, offset); + offset += 4; + if (dataLen !== payload.length - offset) throw new Error('Malformed channel data'); + this.consumeLocalWindow(dataLen); + return payload.subarray(offset, offset + dataLen); + } + + handleExtendedData(payload: Uint8Array): Uint8Array { + if (payload.length < 13) throw new Error('Malformed extended channel data'); + if (readUint32(payload, 1) !== this.localChannelID) throw new Error('Extended channel data has an unexpected recipient'); + const dataLen = readUint32(payload, 9); + if (dataLen !== payload.length - 13) throw new Error('Malformed extended channel data'); + this.consumeLocalWindow(dataLen); + return payload.subarray(13); + } + + handleRequestResult(payload: Uint8Array): void { + this.validateRecipientOnlyMessage(payload, 'channel request result'); + } + + handleEof(payload: Uint8Array): void { + this.validateRecipientOnlyMessage(payload, 'channel EOF'); + if (this.eofReceived) throw new Error('Duplicate channel EOF'); + this.eofReceived = true; + } + + handleClose(payload: Uint8Array): void { + this.validateRecipientOnlyMessage(payload, 'channel close'); + if (this.closeReceived) throw new Error('Duplicate channel close'); + this.closeReceived = true; + } + + buildWindowChange(cols: number, rows: number): Uint8Array { + const payload = new Uint8Array(1 + 4 + WINDOW_CHANGE_FIELD.length + 1 + 16); + let offset = 0; + payload[offset++] = SSH_MSG_CHANNEL_REQUEST; + writeUint32(payload, offset, this.remoteChannelID); + offset += 4; + offset = writeBytes(payload, offset, WINDOW_CHANGE_FIELD); + payload[offset++] = 0x00; + writeUint32(payload, offset, cols); + offset += 4; + writeUint32(payload, offset, rows); + offset += 4; + writeUint32(payload, offset, 0); + offset += 4; + writeUint32(payload, offset, 0); + return payload; + } + + buildWindowAdjust(bytesToAdd: number): Uint8Array { + const payload = new Uint8Array(9); + payload[0] = SSH_MSG_CHANNEL_WINDOW_ADJUST; + writeUint32(payload, 1, this.remoteChannelID); + writeUint32(payload, 5, bytesToAdd); + return payload; + } + + takeLocalWindowAdjustment(threshold: number): number | null { + if (!Number.isInteger(threshold) || threshold < 1) throw new Error('Invalid local channel window threshold'); + if (this.consumedSinceWindowAdjust < threshold) { + return null; + } + const amount = this.consumedSinceWindowAdjust; + this.consumedSinceWindowAdjust = 0; + this.localWindowSize += amount; + return amount; + } + + private consumeLocalWindow(bytes: number): void { + // CHANNEL_EOF ends the sender's ordinary data stream. Some servers still + // send request metadata before CLOSE, but additional channel data is invalid. + if (this.eofReceived || this.closeReceived) throw new Error('Channel data received after EOF or close'); + if (bytes > DEFAULT_MAX_PACKET_SIZE) throw new Error('Channel data exceeds the advertised maximum packet size'); + if (bytes > this.localWindowSize) throw new Error('Channel data exceeds the local receive window'); + this.localWindowSize -= bytes; + this.consumedSinceWindowAdjust += bytes; + } + + private validateRecipientOnlyMessage(payload: Uint8Array, label: string): void { + if (payload.length !== 5) throw new Error(`Malformed ${label}`); + if (readUint32(payload, 1) !== this.localChannelID) throw new Error(`${label} has an unexpected recipient`); + } +} diff --git a/src/ssh/crypto.ts b/src/ssh/crypto.ts new file mode 100644 index 0000000..cf744bd --- /dev/null +++ b/src/ssh/crypto.ts @@ -0,0 +1,199 @@ +import { toBufferSource, writeUint32 } from './utils'; + +function buildMacData(seqNum: number, packet: Uint8Array): Uint8Array { + const data = new Uint8Array(4 + packet.length); + writeUint32(data, 0, seqNum); + data.set(packet, 4); + return data; +} + +export class SSHAESGCMCipher { + private key: CryptoKey | null = null; + private iv: Uint8Array; + private rawKey: Uint8Array; + + constructor(rawKey: Uint8Array, iv: Uint8Array) { + if (iv.length !== 12) throw new Error(`AES-GCM requires a 12-byte IV, got ${iv.length}`); + // Copy the IV so we own it; this is the mutable nonce state + this.iv = new Uint8Array(iv); + this.rawKey = rawKey; + } + + async init(): Promise { + this.key = await crypto.subtle.importKey( + 'raw', + toBufferSource(this.rawKey), + { name: 'AES-GCM', length: this.rawKey.length * 8 }, + false, + ['encrypt', 'decrypt'] + ); + } + + /** + * Increment the 64-bit invocation counter stored in IV bytes [4..11] + * using big-endian carry-over logic, per RFC 5647 section7.1. + */ + private incIV(): void { + for (let i = 11; i >= 4; i--) { + this.iv[i]++; + if (this.iv[i] !== 0) { + break; + } + } + } + + private getAlgorithm(aad?: Uint8Array): Record { + const algorithm: Record = { + name: 'AES-GCM', + iv: this.iv, + tagLength: 128, + }; + if (aad) { + algorithm.additionalData = aad; + } + return algorithm; + } + + async encrypt(plaintext: Uint8Array, _seqNum?: number, aad?: Uint8Array, _commit: boolean = true): Promise { + if (!this.key) throw new Error('Cipher not initialized'); + const algorithm = this.getAlgorithm(aad); + + const encrypted = new Uint8Array( + await crypto.subtle.encrypt(algorithm as unknown as SubtleCryptoEncryptAlgorithm, this.key, toBufferSource(plaintext)) + ); + + this.incIV(); + return encrypted; + } + + async decrypt(ciphertext: Uint8Array, _seqNum?: number, aad?: Uint8Array, _commit: boolean = true): Promise { + if (!this.key) throw new Error('Cipher not initialized'); + const algorithm = this.getAlgorithm(aad); + + try { + const decrypted = new Uint8Array( + await crypto.subtle.decrypt(algorithm as unknown as SubtleCryptoEncryptAlgorithm, this.key, toBufferSource(ciphertext)) + ); + this.incIV(); + return decrypted; + } catch { + return null; + } + } +} + +export class SSHAESCTRCipher { + private key: CryptoKey | null = null; + private counter: Uint8Array; + private rawKey: Uint8Array; + + constructor(rawKey: Uint8Array, iv: Uint8Array) { + if (iv.length !== 16) { + throw new Error(`AES-CTR requires a 16-byte IV, got ${iv.length}`); + } + this.counter = new Uint8Array(iv); + this.rawKey = rawKey; + } + + async init(): Promise { + this.key = await crypto.subtle.importKey( + 'raw', + toBufferSource(this.rawKey), + { name: 'AES-CTR', length: this.rawKey.length * 8 }, + false, + ['encrypt', 'decrypt'] + ); + } + + private incCounter(blocks: number): void { + let carry = blocks; + for (let i = 15; i >= 0 && carry > 0; i--) { + const add = carry % 256; + const sum = this.counter[i] + add; + this.counter[i] = sum & 0xff; + carry = Math.floor(carry / 256) + (sum >>> 8); + } + } + + async encrypt(plaintext: Uint8Array, _seqNum?: number, _aad?: Uint8Array, commit: boolean = true): Promise { + if (!this.key) throw new Error('Cipher not initialized'); + const counter = new Uint8Array(this.counter); + const encrypted = new Uint8Array( + await crypto.subtle.encrypt( + { name: 'AES-CTR', counter, length: 128 } as SubtleCryptoEncryptAlgorithm, + this.key, + toBufferSource(plaintext) + ) + ); + if (commit) { + this.incCounter(Math.ceil(plaintext.length / 16)); + } + return encrypted; + } + + async decrypt(ciphertext: Uint8Array, _seqNum?: number, _aad?: Uint8Array, commit: boolean = true): Promise { + if (!this.key) throw new Error('Cipher not initialized'); + const counter = new Uint8Array(this.counter); + try { + const decrypted = new Uint8Array( + await crypto.subtle.decrypt( + { name: 'AES-CTR', counter, length: 128 } as SubtleCryptoEncryptAlgorithm, + this.key, + toBufferSource(ciphertext) + ) + ); + if (commit) { + this.incCounter(Math.ceil(ciphertext.length / 16)); + } + return decrypted; + } catch { + return null; + } + } +} + +export class SSHHMAC { + private key: CryptoKey | null = null; + private rawKey: Uint8Array; + private hash: 'SHA-256' | 'SHA-512'; + readonly length: number; + + constructor(algorithm: string, rawKey: Uint8Array) { + this.rawKey = rawKey; + if (algorithm === 'hmac-sha2-256') { + this.hash = 'SHA-256'; + this.length = 32; + } else if (algorithm === 'hmac-sha2-512') { + this.hash = 'SHA-512'; + this.length = 64; + } else { + throw new Error(`Unsupported MAC algorithm: ${algorithm}`); + } + } + + async init(): Promise { + this.key = await crypto.subtle.importKey( + 'raw', + toBufferSource(this.rawKey), + { name: 'HMAC', hash: this.hash }, + false, + ['sign', 'verify'] + ); + } + + async sign(packet: Uint8Array, seqNum: number): Promise { + if (!this.key) throw new Error('MAC not initialized'); + return new Uint8Array(await crypto.subtle.sign('HMAC', this.key, toBufferSource(buildMacData(seqNum, packet)))); + } + + async verify(packet: Uint8Array, seqNum: number, expected: Uint8Array): Promise { + if (!this.key) throw new Error('MAC not initialized'); + return crypto.subtle.verify('HMAC', this.key, toBufferSource(expected), toBufferSource(buildMacData(seqNum, packet))); + } +} + +export const REKEY_THRESHOLD = 1 << 30; + +export function shouldRekey(seqNum: number): boolean { + return seqNum >= REKEY_THRESHOLD; +} diff --git a/src/ssh/kex-curve25519.ts b/src/ssh/kex-curve25519.ts new file mode 100644 index 0000000..f51e6f7 --- /dev/null +++ b/src/ssh/kex-curve25519.ts @@ -0,0 +1,146 @@ +import { SSH_MSG_KEX_ECDH_INIT } from '../types'; +import { concat, readUint32, encodeString, toBufferSource, x25519SecretToSSHMPInt } from './utils'; + +type X25519SubtleCrypto = { + generateKey( + algorithm: { name: 'X25519' }, + extractable: boolean, + keyUsages: string[] + ): Promise; + exportKey(format: 'raw', key: CryptoKey): Promise; + importKey( + format: 'raw', + keyData: ArrayBuffer | ArrayBufferView, + algorithm: { name: 'X25519' }, + extractable: boolean, + keyUsages: string[] + ): Promise; + deriveBits( + algorithm: { name: 'X25519'; public: CryptoKey }, + baseKey: CryptoKey, + length: number + ): Promise; +}; + +function x25519Subtle(): X25519SubtleCrypto { + return crypto.subtle as unknown as X25519SubtleCrypto; +} + +function isAllZero(bytes: Uint8Array): boolean { + for (const byte of bytes) { + if (byte !== 0) return false; + } + return true; +} + +export type Curve25519KeyPair = CryptoKeyPair; + +export class Curve25519KeyExchange { + static async generateKeyPair(): Promise { + return x25519Subtle().generateKey( + { name: 'X25519' }, + true, + ['deriveBits'] + ); + } + + static async exportRawPublicKey(keyPair: Curve25519KeyPair): Promise { + return new Uint8Array( + await x25519Subtle().exportKey('raw', keyPair.publicKey) + ); + } + + static buildInit(clientRawPublicKey: Uint8Array): Uint8Array { + if (clientRawPublicKey.length !== 32) { + throw new Error(`Invalid Curve25519 client public key length: ${clientRawPublicKey.length}`); + } + + return concat( + new Uint8Array([SSH_MSG_KEX_ECDH_INIT]), + encodeString(clientRawPublicKey) + ); + } + + static parseReply(data: Uint8Array): { + hostKey: Uint8Array; + serverRawPublicKey: Uint8Array; + signature: Uint8Array; + } { + if (data.length < 13 || data[0] !== 31) throw new Error('Malformed Curve25519 reply'); + let offset = 1; + + const hostKeyLen = readUint32(data, offset); + offset += 4; + if (hostKeyLen === 0 || hostKeyLen > data.length - offset) throw new Error('Malformed Curve25519 host key'); + const hostKey = data.slice(offset, offset + hostKeyLen); + offset += hostKeyLen; + + if (offset + 4 > data.length) throw new Error('Malformed Curve25519 server key'); + const qSLen = readUint32(data, offset); + offset += 4; + if (qSLen !== 32 || qSLen > data.length - offset) throw new Error('Malformed Curve25519 server key'); + const serverRawPublicKey = data.slice(offset, offset + qSLen); + offset += qSLen; + + if (offset + 4 > data.length) throw new Error('Malformed Curve25519 signature'); + const sigLen = readUint32(data, offset); + offset += 4; + if (sigLen === 0 || sigLen !== data.length - offset) throw new Error('Malformed Curve25519 signature'); + const signature = data.slice(offset, offset + sigLen); + + return { hostKey, serverRawPublicKey, signature }; + } + + static async computeSharedSecret( + privateKey: CryptoKey, + serverRawPublicKey: Uint8Array + ): Promise { + if (serverRawPublicKey.length !== 32) { + throw new Error(`Invalid Curve25519 server public key length: ${serverRawPublicKey.length}`); + } + + const serverKey = await x25519Subtle().importKey( + 'raw', + serverRawPublicKey, + { name: 'X25519' }, + false, + [] + ); + + const sharedSecret = new Uint8Array( + await x25519Subtle().deriveBits( + { name: 'X25519', public: serverKey }, + privateKey, + 256 + ) + ); + if (isAllZero(sharedSecret)) { + throw new Error('Curve25519 key exchange failed: all-zero shared secret'); + } + return x25519SecretToSSHMPInt(sharedSecret); + } + + static async computeExchangeHash( + clientVersion: string, + serverVersion: string, + clientKEXInit: Uint8Array, + serverKEXInit: Uint8Array, + hostKey: Uint8Array, + clientRawPublicKey: Uint8Array, + serverRawPublicKey: Uint8Array, + sharedSecret: Uint8Array + ): Promise { + const v_c = encodeString(clientVersion); + const v_s = encodeString(serverVersion); + const i_c = encodeString(clientKEXInit); + const i_s = encodeString(serverKEXInit); + const k_s = encodeString(hostKey); + const e = encodeString(clientRawPublicKey); + const f = encodeString(serverRawPublicKey); + const k = sharedSecret; + + const data = concat(v_c, v_s, i_c, i_s, k_s, e, f, k); + const hashBuffer = await crypto.subtle.digest('SHA-256', toBufferSource(data)); + return new Uint8Array(hashBuffer); + } +} diff --git a/src/ssh/kex-ecdh.ts b/src/ssh/kex-ecdh.ts new file mode 100644 index 0000000..94207c3 --- /dev/null +++ b/src/ssh/kex-ecdh.ts @@ -0,0 +1,137 @@ +import { SSH_MSG_KEX_ECDH_INIT } from '../types'; +import { concat, readUint32, encodeString, toBufferSource, toSSHMPInt } from './utils'; + +type ECDHSubtleCrypto = { + generateKey( + algorithm: { name: 'ECDH'; namedCurve: 'P-256' }, + extractable: boolean, + keyUsages: string[] + ): Promise; + exportKey(format: 'raw', key: CryptoKey): Promise; + importKey( + format: 'raw', + keyData: ArrayBuffer | ArrayBufferView, + algorithm: { name: 'ECDH'; namedCurve: 'P-256' }, + extractable: boolean, + keyUsages: string[] + ): Promise; + deriveBits( + algorithm: { name: 'ECDH'; public: CryptoKey }, + baseKey: CryptoKey, + length: number + ): Promise; +}; + +function ecdhSubtle(): ECDHSubtleCrypto { + return crypto.subtle as unknown as ECDHSubtleCrypto; +} + +function isAllZero(bytes: Uint8Array): boolean { + for (const byte of bytes) { + if (byte !== 0) return false; + } + return true; +} + +export class ECDHKeyExchange { + static async generateKeyPair(): Promise { + return ecdhSubtle().generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'] + ); + } + + static async exportRawPublicKey(keyPair: CryptoKeyPair): Promise { + return new Uint8Array( + await ecdhSubtle().exportKey('raw', keyPair.publicKey) + ); + } + + static buildInit(clientRawPublicKey: Uint8Array): Uint8Array { + return concat( + new Uint8Array([SSH_MSG_KEX_ECDH_INIT]), + encodeString(clientRawPublicKey) + ); + } + + static parseReply(data: Uint8Array): { + hostKey: Uint8Array; + serverRawPublicKey: Uint8Array; + signature: Uint8Array; + } { + if (data.length < 13 || data[0] !== 31) throw new Error('Malformed ECDH reply'); + let offset = 1; + + const hostKeyLen = readUint32(data, offset); + offset += 4; + if (hostKeyLen === 0 || hostKeyLen > data.length - offset) throw new Error('Malformed ECDH host key'); + const hostKey = data.slice(offset, offset + hostKeyLen); + offset += hostKeyLen; + + if (offset + 4 > data.length) throw new Error('Malformed ECDH server key'); + const qSLen = readUint32(data, offset); + offset += 4; + if (qSLen === 0 || qSLen > data.length - offset) throw new Error('Malformed ECDH server key'); + const serverRawPublicKey = data.slice(offset, offset + qSLen); + offset += qSLen; + + if (offset + 4 > data.length) throw new Error('Malformed ECDH signature'); + const sigLen = readUint32(data, offset); + offset += 4; + if (sigLen === 0 || sigLen !== data.length - offset) throw new Error('Malformed ECDH signature'); + const signature = data.slice(offset, offset + sigLen); + + return { hostKey, serverRawPublicKey, signature }; + } + + static async computeSharedSecret( + privateKey: CryptoKey, + serverRawPublicKey: Uint8Array + ): Promise { + const serverKey = await ecdhSubtle().importKey( + 'raw', + serverRawPublicKey, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + + const sharedBits = await ecdhSubtle().deriveBits( + { name: 'ECDH', public: serverKey }, + privateKey, + 256 + ); + + const sharedSecret = new Uint8Array(sharedBits); + if (isAllZero(sharedSecret)) { + throw new Error('ECDH key exchange failed: all-zero shared secret'); + } + + return toSSHMPInt(sharedSecret); + } + + static async computeExchangeHash( + clientVersion: string, + serverVersion: string, + clientKEXInit: Uint8Array, + serverKEXInit: Uint8Array, + hostKey: Uint8Array, + clientRawPublicKey: Uint8Array, + serverRawPublicKey: Uint8Array, + sharedSecret: Uint8Array + ): Promise { + const v_c = encodeString(clientVersion); + const v_s = encodeString(serverVersion); + const i_c = encodeString(clientKEXInit); + const i_s = encodeString(serverKEXInit); + const k_s = encodeString(hostKey); + const e = encodeString(clientRawPublicKey); + const f = encodeString(serverRawPublicKey); + const k = sharedSecret; + + const data = concat(v_c, v_s, i_c, i_s, k_s, e, f, k); + const hashBuffer = await crypto.subtle.digest('SHA-256', toBufferSource(data)); + return new Uint8Array(hashBuffer); + } +} diff --git a/src/ssh/kex.ts b/src/ssh/kex.ts new file mode 100644 index 0000000..58415b8 --- /dev/null +++ b/src/ssh/kex.ts @@ -0,0 +1,143 @@ +import { SSH_MSG_KEXINIT, KEXInitMessage } from '../types'; +import { + SUPPORTED_ENCRYPTION_ALGORITHMS, + SUPPORTED_KEX_ALGORITHMS, + SUPPORTED_MAC_ALGORITHMS +} from './algorithms'; +import { concat } from './utils'; + +export class KEXInitBuilder { + static build(): Uint8Array { + const parts: Uint8Array[] = []; + + parts.push(new Uint8Array([SSH_MSG_KEXINIT])); + + const cookie = new Uint8Array(16); + crypto.getRandomValues(cookie); + parts.push(cookie); + + const algorithmLists = [ + ['ext-info-c', ...SUPPORTED_KEX_ALGORITHMS].join(','), + 'ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256', + SUPPORTED_ENCRYPTION_ALGORITHMS.join(','), + SUPPORTED_ENCRYPTION_ALGORITHMS.join(','), + SUPPORTED_MAC_ALGORITHMS.join(','), + SUPPORTED_MAC_ALGORITHMS.join(','), + 'none', + 'none', + '', + '', + ]; + + for (const name of algorithmLists) { + const encoded = new TextEncoder().encode(name); + const len = new Uint8Array(4); + new DataView(len.buffer).setUint32(0, encoded.length, false); + parts.push(len); + parts.push(encoded); + } + + parts.push(new Uint8Array([0])); + + const reserved = new Uint8Array(4); + parts.push(reserved); + + return concat(...parts); + } +} + +export function parseKEXInit(data: Uint8Array): KEXInitMessage { + if (data.length < 22 || data[0] !== SSH_MSG_KEXINIT) throw new Error('Malformed KEXINIT header'); + let offset = 1; + + offset += 16; + + const lists: string[] = []; + for (let i = 0; i < 10; i++) { + if (offset + 4 > data.length) { + throw new Error(`Malformed KEXINIT: truncated length field at list ${i}, offset=${offset}, dataLen=${data.length}`); + } + const len = (data[offset] << 24) | (data[offset+1] << 16) | + (data[offset+2] << 8) | data[offset+3]; + offset += 4; + if (len < 0 || offset + len > data.length) { + throw new Error(`Malformed KEXINIT: list ${i} length ${len} exceeds packet boundary (offset=${offset}, dataLen=${data.length})`); + } + let name: string; + try { name = new TextDecoder('utf-8', { fatal: true }).decode(data.subarray(offset, offset + len)); } + catch { throw new Error(`Malformed KEXINIT: invalid UTF-8 in list ${i}`); } + if (name.includes('\0') || name.split(',').some((entry) => entry.length > 0 && !/^[A-Za-z0-9@._+-]+$/.test(entry))) { + throw new Error(`Malformed KEXINIT algorithm list ${i}`); + } + lists.push(name); + offset += len; + } + + if (offset + 5 > data.length) throw new Error('Malformed KEXINIT trailer'); + const firstKexPacketFollows = data[offset] !== 0; + offset += 5; // boolean first_kex_packet_follows and uint32 reserved + if (offset !== data.length) throw new Error('Malformed KEXINIT trailing data'); + + return { + kexAlgorithms: lists[0].split(','), + hostKeyAlgorithms: lists[1].split(','), + encryptionC2S: lists[2].split(','), + encryptionS2C: lists[3].split(','), + macC2S: lists[4].split(','), + macS2C: lists[5].split(','), + compressionC2S: lists[6].split(','), + compressionS2C: lists[7].split(','), + firstKexPacketFollows, + }; +} + +export function negotiate(clientList: string[], serverList: string[], category: string = 'algorithm'): string { + for (const algo of clientList) { + if (serverList.includes(algo)) return algo; + } + throw new Error(`No common ${category}: client=[${clientList.join(',')}] server=[${serverList.join(',')}]`); +} +export function parseServerSigAlgs(payload: Uint8Array): string[] { + if (payload[0] !== 7) throw new Error('Malformed EXT_INFO message type'); + let offset = 1; // Skip the message type. + + if (offset + 4 > payload.length) throw new Error('Malformed EXT_INFO extension count'); + const nrExtensions = (payload[offset] << 24) | (payload[offset+1] << 16) | + (payload[offset+2] << 8) | payload[offset+3]; + offset += 4; + + if (nrExtensions > 1024) throw new Error(`EXT_INFO extension count is too large (${nrExtensions})`); + + let serverSigAlgs: string[] = []; + for (let i = 0; i < nrExtensions; i++) { + if (offset + 4 > payload.length) throw new Error('Malformed EXT_INFO extension name length'); + const nameLen = (payload[offset] << 24) | (payload[offset+1] << 16) | + (payload[offset+2] << 8) | payload[offset+3]; + offset += 4; + if (offset + nameLen > payload.length) throw new Error('Malformed EXT_INFO extension name'); + let name: string; + try { name = new TextDecoder('utf-8', { fatal: true }).decode(payload.subarray(offset, offset + nameLen)); } + catch { throw new Error('Malformed EXT_INFO extension name'); } + offset += nameLen; + + if (offset + 4 > payload.length) throw new Error('Malformed EXT_INFO extension value length'); + const valueLen = (payload[offset] << 24) | (payload[offset+1] << 16) | + (payload[offset+2] << 8) | payload[offset+3]; + offset += 4; + if (offset + valueLen > payload.length) throw new Error('Malformed EXT_INFO extension value'); + const valueBytes = payload.subarray(offset, offset + valueLen); + offset += valueLen; + + if (name === 'server-sig-algs') { + let value: string; + try { value = new TextDecoder('utf-8', { fatal: true }).decode(valueBytes); } + catch { throw new Error('Malformed server-sig-algs extension'); } + serverSigAlgs = value.split(',').filter((algorithm) => /^[A-Za-z0-9@._+-]+$/.test(algorithm)); + } + } + if (offset !== payload.length) throw new Error('Malformed EXT_INFO trailing data'); + return serverSigAlgs; +} +export function filterExtInfo(list: string[]): string[] { + return list.filter(a => !a.startsWith('ext-info-')); +} diff --git a/src/ssh/keys.ts b/src/ssh/keys.ts new file mode 100644 index 0000000..14e0a71 --- /dev/null +++ b/src/ssh/keys.ts @@ -0,0 +1,72 @@ +import { SessionKeys } from '../types'; +import { concat, toBufferSource } from './utils'; + +export class KeyDerivation { + static async deriveKeys( + sharedSecret: Uint8Array, + exchangeHash: Uint8Array, + sessionID: Uint8Array, + ivLengthC2S: number = 12, + ivLengthS2C: number = 12, + integrityKeyLengthC2S: number = 32, + integrityKeyLengthS2C: number = 32, + encryptionKeyLengthC2S: number = 32, + encryptionKeyLengthS2C: number = 32, + ): Promise { + const ivC2S = await this.expandKey(sharedSecret, exchangeHash, 'A', sessionID, ivLengthC2S); + const ivS2C = await this.expandKey(sharedSecret, exchangeHash, 'B', sessionID, ivLengthS2C); + const keyC2S = await this.expandKey(sharedSecret, exchangeHash, 'C', sessionID, encryptionKeyLengthC2S); + const keyS2C = await this.expandKey(sharedSecret, exchangeHash, 'D', sessionID, encryptionKeyLengthS2C); + const intKeyC2S = await this.expandKey(sharedSecret, exchangeHash, 'E', sessionID, integrityKeyLengthC2S); + const intKeyS2C = await this.expandKey(sharedSecret, exchangeHash, 'F', sessionID, integrityKeyLengthS2C); + + return { + ivClientToServer: ivC2S, + ivServerToClient: ivS2C, + encKeyClientToServer: keyC2S, + encKeyServerToClient: keyS2C, + integrityKeyC2S: intKeyC2S, + integrityKeyS2C: intKeyS2C, + sessionID, + }; + } + + private static async expandKey( + K: Uint8Array, + H: Uint8Array, + X: string, + sessionId: Uint8Array, + needed: number + ): Promise { + const hashLen = 32; + const rounds = Math.ceil(needed / hashLen); + const result = new Uint8Array(needed); + let offset = 0; + + const XBytes = new TextEncoder().encode(X); + + // K1 = HASH(K || H || X || sessionId) + let currentHash = new Uint8Array( + await crypto.subtle.digest('SHA-256', + toBufferSource(concat(K, H, XBytes, sessionId)) + ) + ); + result.set(currentHash.slice(0, Math.min(hashLen, needed)), 0); + offset += hashLen; + + // For Kn (n > 1): Kn = HASH(K || H || K1 || ... || Kn-1) + for (let i = 1; i < rounds; i++) { + const prevKeys = result.slice(0, offset); + currentHash = new Uint8Array( + await crypto.subtle.digest('SHA-256', + toBufferSource(concat(K, H, prevKeys)) + ) + ); + const remaining = needed - offset; + result.set(currentHash.slice(0, Math.min(hashLen, remaining)), offset); + offset += hashLen; + } + + return result; + } +} diff --git a/src/ssh/packet.ts b/src/ssh/packet.ts new file mode 100644 index 0000000..c79f177 --- /dev/null +++ b/src/ssh/packet.ts @@ -0,0 +1,311 @@ +import { SSHPacket } from '../types'; +import { readUint32, writeUint32 } from './utils'; + +const EMPTY_BUFFER = new Uint8Array(0); +const COMPACT_CHUNKS_THRESHOLD = 32; +const MAX_PACKET_SIZE = 256 * 1024; // 256KB - RFC 4253 section6.1 requires at least 35000 bytes +const MIN_PACKET_LENGTH = 5; // padding_length + at least 4 bytes of padding + +export function nextSequenceNumber(value: number): number { + return (value + 1) >>> 0; +} + +export class SSHPacketParser { + private chunks: Uint8Array[] = []; + private chunkIndex: number = 0; + private readOffset: number = 0; + private bufferedLength: number = 0; + private seqNum: number = 0; + + feed(data: Uint8Array): void { + if (data.length === 0) { + return; + } + + this.chunks.push(data); + this.bufferedLength += data.length; + } + + private peekBytes(bytes: number): Uint8Array | null { + if (bytes === 0) { + return EMPTY_BUFFER; + } + + if (this.bufferedLength < bytes) { + return null; + } + + const first = this.chunks[this.chunkIndex]; + if (first) { + const firstAvailable = first.length - this.readOffset; + if (firstAvailable >= bytes) { + return first.subarray(this.readOffset, this.readOffset + bytes); + } + } + + const result = new Uint8Array(bytes); + let copied = 0; + + for (let i = this.chunkIndex; copied < bytes; i++) { + const chunk = this.chunks[i]; + const start = i === this.chunkIndex ? this.readOffset : 0; + const take = Math.min(chunk.length - start, bytes - copied); + result.set(chunk.subarray(start, start + take), copied); + copied += take; + } + + return result; + } + + private readBytes(bytes: number): Uint8Array | null { + const result = this.peekBytes(bytes); + if (!result) { + return null; + } + + this.consumeBytes(bytes); + return result; + } + + private consumeBytes(bytes: number): void { + if (bytes === 0) { + return; + } + + if (bytes > this.bufferedLength) { + throw new Error('Cannot consume more bytes than buffered'); + } + + this.bufferedLength -= bytes; + + while (bytes > 0) { + const chunk = this.chunks[this.chunkIndex]; + const available = chunk.length - this.readOffset; + + if (bytes < available) { + this.readOffset += bytes; + this.compactChunks(); + return; + } + + bytes -= available; + this.chunkIndex++; + this.readOffset = 0; + } + + this.compactChunks(); + } + + private compactChunks(): void { + if (this.bufferedLength === 0) { + this.chunks = []; + this.chunkIndex = 0; + this.readOffset = 0; + return; + } + + // Discard fully consumed chunks (Array.slice creates a new reference array, + if ( + this.chunkIndex > COMPACT_CHUNKS_THRESHOLD && + this.chunkIndex * 2 >= this.chunks.length + ) { + this.chunks = this.chunks.slice(this.chunkIndex); + this.chunkIndex = 0; + } + } + + async nextPacket(blockSize: number, decrypt: ( + data: Uint8Array, seq: number, aad?: Uint8Array, commit?: boolean + ) => Uint8Array | Promise | null, hasAuthTag: boolean = false, + macLength: number = 0, + verifyMac?: (packet: Uint8Array, mac: Uint8Array, seq: number) => boolean | Promise): Promise { + if (!Number.isInteger(blockSize) || blockSize < 4) { + throw new Error(`Invalid SSH cipher block size ${blockSize}`); + } + + if (hasAuthTag) { + const lengthBytes = this.peekBytes(4); + if (!lengthBytes) return null; + + const packetLength = readUint32(lengthBytes, 0); + if (packetLength < MIN_PACKET_LENGTH) throw new Error(`Packet length ${packetLength} is below the SSH minimum`); + if (packetLength > MAX_PACKET_SIZE) { + throw new Error(`Packet length ${packetLength} exceeds maximum allowed size ${MAX_PACKET_SIZE}`); + } + // RFC 5647 section 7.2 makes packet_length AAD. The remaining + // packetLength bytes are the AES-GCM plaintext and must be block-aligned. + if (packetLength % blockSize !== 0) { + throw new Error(`Authenticated packet length ${packetLength} is not aligned to block size ${blockSize}`); + } + const expectedSize = 4 + packetLength + 16; + + if (this.bufferedLength < expectedSize) return null; + + const raw = this.readBytes(expectedSize); + if (!raw) return null; + + const lengthField = raw.subarray(0, 4); + const dataToDecrypt = raw.subarray(4); + const decrypted = await decrypt(dataToDecrypt, this.seqNum, lengthField, true); + if (!decrypted) throw new Error('Authenticated packet decryption failed'); + if (decrypted.length !== packetLength) { + throw new Error(`Authenticated packet body length ${decrypted.length} does not match packet length ${packetLength}`); + } + + const paddingLength = decrypted[0]; + if (paddingLength < 4 || paddingLength >= packetLength) { + throw new Error(`Invalid padding length ${paddingLength} for packet length ${packetLength}`); + } + const payload = decrypted.subarray(1, 1 + packetLength - 1 - paddingLength); + + this.seqNum = nextSequenceNumber(this.seqNum); + + return { + length: packetLength, + paddingLength, + payload, + mac: raw.subarray(4 + packetLength), + }; + } + + const encryptedHeader = this.peekBytes(blockSize); + if (!encryptedHeader) return null; + + const header = await decrypt( + encryptedHeader, this.seqNum, undefined, false + ); + if (!header) throw new Error('Packet header decryption failed'); + + const packetLength = readUint32(header, 0); + if (packetLength < MIN_PACKET_LENGTH) throw new Error(`Packet length ${packetLength} is below the SSH minimum`); + if (packetLength > MAX_PACKET_SIZE) { + throw new Error(`Packet length ${packetLength} exceeds maximum allowed size ${MAX_PACKET_SIZE}`); + } + + const totalSize = 4 + packetLength; + if (totalSize % blockSize !== 0) { + throw new Error(`Packet size ${totalSize} is not aligned to block size ${blockSize}`); + } + + if (this.bufferedLength < totalSize + macLength) return null; + + const encryptedPacket = this.readBytes(totalSize); + const mac = this.readBytes(macLength); + if (!encryptedPacket || !mac) return null; + + const decrypted = await decrypt(encryptedPacket, this.seqNum, undefined, true); + if (!decrypted) throw new Error('Packet decryption failed'); + if (decrypted.length !== totalSize) { + throw new Error(`Decrypted packet size ${decrypted.length} does not match framed size ${totalSize}`); + } + + if (verifyMac && macLength > 0) { + const macValid = await verifyMac(decrypted, mac, this.seqNum); + if (!macValid) { + throw new Error('Invalid packet MAC'); + } + } + + const paddingLength = decrypted[4]; + if (paddingLength < 4 || paddingLength >= packetLength) { + throw new Error(`Invalid padding length ${paddingLength} for packet length ${packetLength}`); + } + const payload = decrypted.subarray(5, 5 + packetLength - 1 - paddingLength); + + this.seqNum = nextSequenceNumber(this.seqNum); + + return { + length: packetLength, + paddingLength, + payload, + mac, + }; + } + + getSeqNum(): number { + return this.seqNum; + } + + getBufferLength(): number { + return this.bufferedLength; + } +} + +export class SSHPacketBuilder { + static async build( + payload: Uint8Array, + blockSize: number, + encrypt: ((data: Uint8Array, seq: number, aad?: Uint8Array) => Uint8Array | Promise) | null, + seqNum: number, + hasAuthTag: boolean = false, + mac?: (packet: Uint8Array, seq: number) => Uint8Array | Promise + ): Promise { + return SSHPacketBuilder.buildWithPayloadWriter( + payload.length, + (packet, offset) => packet.set(payload, offset), + blockSize, + encrypt, + seqNum, + hasAuthTag, + mac + ); + } + + static async buildWithPayloadWriter( + payloadLength: number, + writePayload: (packet: Uint8Array, offset: number) => void, + blockSize: number, + encrypt: ((data: Uint8Array, seq: number, aad?: Uint8Array) => Uint8Array | Promise) | null, + seqNum: number, + hasAuthTag: boolean = false, + mac?: (packet: Uint8Array, seq: number) => Uint8Array | Promise + ): Promise { + const packetLength = 1 + payloadLength; + // For AES-GCM (hasAuthTag), padding aligns the encrypted portion + // (padding_length + payload + padding) to blockSize. + // The 4-byte packet_length is AAD, NOT part of the encrypted data. + // For non-GCM, padding aligns the full packet (4 + data) to blockSize. + const alignBase = hasAuthTag + ? (1 + payloadLength) % blockSize // encrypted portion only + : (4 + packetLength) % blockSize; // full packet including length + const paddingNeeded = blockSize - (alignBase || blockSize); + const paddingLength = paddingNeeded < 4 + ? paddingNeeded + blockSize + : paddingNeeded; + + const totalLength = 4 + 1 + payloadLength + paddingLength; + const packet = new Uint8Array(totalLength); + + const pl = 1 + payloadLength + paddingLength; + writeUint32(packet, 0, pl); + + packet[4] = paddingLength; + + writePayload(packet, 5); + + crypto.getRandomValues(packet.subarray(5 + payloadLength)); + + if (encrypt) { + if (hasAuthTag) { + const lengthField = packet.subarray(0, 4); + const dataToEncrypt = packet.subarray(4); + const encryptedData = await encrypt(dataToEncrypt, seqNum, lengthField); + const result = new Uint8Array(4 + encryptedData.length); + result.set(lengthField, 0); + result.set(encryptedData, 4); + return result; + } + const encryptedPacket = await encrypt(packet, seqNum); + if (mac) { + const macBytes = await mac(packet, seqNum); + const result = new Uint8Array(encryptedPacket.length + macBytes.length); + result.set(encryptedPacket, 0); + result.set(macBytes, encryptedPacket.length); + return result; + } + return encryptedPacket; + } + + return packet; + } +} diff --git a/src/ssh/transport.ts b/src/ssh/transport.ts new file mode 100644 index 0000000..c81c156 --- /dev/null +++ b/src/ssh/transport.ts @@ -0,0 +1,43 @@ +// RFC 4253 exchange hashes use this exact line without its CRLF terminator. +export const SSH_VERSION = 'SSH-2.0-CFWorkersWebSSH_1.0'; + +export function isSSH2Identification(value: string): boolean { + return value.startsWith('SSH-2.0-') || value.startsWith('SSH-1.99-'); +} + +export class SSHTransport { + private remoteVersion: string = ''; + private readonly localVersion: string = SSH_VERSION; + private versionBuffer: string = ''; + + handleVersionExchange(data: string): boolean { + this.versionBuffer += data; + + const lines = this.versionBuffer.split('\r\n'); + for (const line of lines) { + if (line.startsWith('SSH-')) { + this.remoteVersion = line; + return true; + } + } + + return false; + } + + sendVersion(socket: { write: (data: Uint8Array) => void }): void { + const encoder = new TextEncoder(); + socket.write(encoder.encode(SSH_VERSION + '\r\n')); + } + + getRemoteVersion(): string { + return this.remoteVersion; + } + + getLocalVersion(): string { + return this.localVersion; + } + + getLocalIdentification(): Uint8Array { + return new TextEncoder().encode(`${this.localVersion}\r\n`); + } +} diff --git a/src/ssh/utils.ts b/src/ssh/utils.ts new file mode 100644 index 0000000..05a69c1 --- /dev/null +++ b/src/ssh/utils.ts @@ -0,0 +1,94 @@ +const textEncoder = new TextEncoder(); + +/** Workers WebCrypto currently types BufferSource as ArrayBuffer-backed only. */ +export function toBufferSource(bytes: Uint8Array): Uint8Array { + return new Uint8Array(bytes); +} + +export function concat(...arrays: Uint8Array[]): Uint8Array { + let totalLength = 0; + for (const arr of arrays) { + totalLength += arr.length; + } + + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} + +export function readUint32(data: Uint8Array, offset: number): number { + return ( + (data[offset] << 24) | + (data[offset + 1] << 16) | + (data[offset + 2] << 8) | + data[offset + 3] + ) >>> 0; +} + +export function writeUint32(data: Uint8Array, offset: number, value: number): void { + data[offset] = (value >>> 24) & 0xff; + data[offset + 1] = (value >>> 16) & 0xff; + data[offset + 2] = (value >>> 8) & 0xff; + data[offset + 3] = value & 0xff; +} + +export function encodeUint32(value: number): Uint8Array { + const buf = new Uint8Array(4); + writeUint32(buf, 0, value); + return buf; +} + +export function encodeString(input: string | Uint8Array): Uint8Array { + const encoded = typeof input === 'string' + ? textEncoder.encode(input) + : input; + const result = new Uint8Array(4 + encoded.length); + writeUint32(result, 0, encoded.length); + result.set(encoded, 4); + return result; +} + +export function toSSHMPInt(bytes: Uint8Array): Uint8Array { + let start = 0; + while (start < bytes.length - 1 && bytes[start] === 0) { + start++; + } + const significant = bytes.subarray(start); + const needsLeadingZero = significant.length > 0 && (significant[0] & 0x80) !== 0; + const valueOffset = needsLeadingZero ? 5 : 4; + const result = new Uint8Array(valueOffset + significant.length); + + writeUint32(result, 0, significant.length + (needsLeadingZero ? 1 : 0)); + result.set(significant, valueOffset); + + return result; +} + +/** RFC 8731 interprets the X25519 byte string as a network-order integer. */ +export function x25519SecretToSSHMPInt(bytes: Uint8Array): Uint8Array { + if (bytes.length !== 32) throw new Error(`X25519 shared secret must be 32 bytes, got ${bytes.length}`); + return toSSHMPInt(bytes); +} + +export function extractRawECDHPoint(blob: Uint8Array): Uint8Array { + let offset = 0; + + const keyTypeLen = readUint32(blob, offset); + offset += 4 + keyTypeLen; + + const curveLen = readUint32(blob, offset); + offset += 4 + curveLen; + + const pointLen = readUint32(blob, offset); + offset += 4; + + return blob.subarray(offset, offset + pointLen); +} + +export function encodePrefixedString(str: string): Uint8Array { + return encodeString(str); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..fee753a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,138 @@ +export interface SSHPacket { + length: number; + paddingLength: number; + payload: Uint8Array; + mac?: Uint8Array; +} + +export interface KEXInitMessage { + kexAlgorithms: string[]; + hostKeyAlgorithms: string[]; + encryptionC2S: string[]; + encryptionS2C: string[]; + macC2S: string[]; + macS2C: string[]; + compressionC2S: string[]; + compressionS2C: string[]; + firstKexPacketFollows: boolean; +} + +export interface SessionKeys { + ivClientToServer: Uint8Array; + ivServerToClient: Uint8Array; + encKeyClientToServer: Uint8Array; + encKeyServerToClient: Uint8Array; + integrityKeyC2S: Uint8Array; + integrityKeyS2C: Uint8Array; + sessionID: Uint8Array; +} + +export interface SSHConnectionConfig { + type: 'connect'; + host: string; + port: number; + username: string; + authMethod: 'password' | 'publickey'; + password?: string; + privateKey?: string; + cols: number; + rows: number; + term: string; + expectedFingerprint?: string; +} + +export interface TerminalSize { + cols: number; + rows: number; +} + +export interface Env { + SSH_SESSIONS: DurableObjectNamespace; + ASSETS?: Fetcher; + ACCESS_TOKEN?: string; + ALLOW_ANONYMOUS?: string; + CONNECT_TIMEOUT_MS?: string; +} + +export const SSH_MSG_DISCONNECT = 1; +export const SSH_MSG_IGNORE = 2; +export const SSH_MSG_UNIMPLEMENTED = 3; +export const SSH_MSG_DEBUG = 4; +export const SSH_MSG_SERVICE_REQUEST = 5; +export const SSH_MSG_SERVICE_ACCEPT = 6; +export const SSH_MSG_EXT_INFO = 7; +export const SSH_MSG_KEXINIT = 20; +export const SSH_MSG_NEWKEYS = 21; +export const SSH_MSG_KEX_ECDH_INIT = 30; +export const SSH_MSG_KEX_ECDH_REPLY = 31; +export const SSH_MSG_USERAUTH_REQUEST = 50; +export const SSH_MSG_USERAUTH_FAILURE = 51; +export const SSH_MSG_USERAUTH_SUCCESS = 52; +export const SSH_MSG_GLOBAL_REQUEST = 80; +export const SSH_MSG_REQUEST_SUCCESS = 81; +export const SSH_MSG_REQUEST_FAILURE = 82; +export const SSH_MSG_CHANNEL_OPEN = 90; +export const SSH_MSG_CHANNEL_OPEN_CONFIRMATION = 91; +export const SSH_MSG_CHANNEL_OPEN_FAILURE = 92; +export const SSH_MSG_CHANNEL_WINDOW_ADJUST = 93; +export const SSH_MSG_CHANNEL_DATA = 94; +export const SSH_MSG_CHANNEL_EXTENDED_DATA = 95; +export const SSH_MSG_CHANNEL_EOF = 96; +export const SSH_MSG_CHANNEL_CLOSE = 97; +export const SSH_MSG_CHANNEL_REQUEST = 98; +export const SSH_MSG_CHANNEL_SUCCESS = 99; +export const SSH_MSG_CHANNEL_FAILURE = 100; + +const HOST_RE = /^(?=.{1,253}$)(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)*[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i; +const IPV4_RE = /^(?:\d{1,3}\.){3}\d{1,3}$/; + +export function normalizeTerminalSize(cols: unknown, rows: unknown): TerminalSize | null { + if (typeof cols !== 'number' || typeof rows !== 'number' || !Number.isFinite(cols) || !Number.isFinite(rows)) { + return null; + } + const size = { cols: Math.floor(cols), rows: Math.floor(rows) }; + return size.cols >= 10 && size.cols <= 1000 && size.rows >= 5 && size.rows <= 1000 ? size : null; +} + +function isValidHost(host: string): boolean { + const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; + if (bare.includes(':')) { + try { + return new URL(`http://[${bare}]`).hostname.length > 2; + } catch { + return false; + } + } + if (/^[\d.]+$/.test(bare)) { + if (!IPV4_RE.test(bare)) return false; + return bare.split('.').every((part) => Number(part) <= 255 && (part === '0' || !part.startsWith('0'))); + } + return HOST_RE.test(bare); +} + +export function parseConnectMessage(value: unknown): SSHConnectionConfig { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid connect message'); + const raw = value as Record; + if (raw.type !== 'connect') throw new Error('The first message must be type "connect"'); + if (typeof raw.host !== 'string' || !isValidHost(raw.host.trim())) throw new Error('Invalid SSH host'); + if (typeof raw.port !== 'number' || !Number.isInteger(raw.port) || raw.port < 1 || raw.port > 65535) throw new Error('Invalid SSH port'); + if (typeof raw.username !== 'string' || raw.username.length < 1 || raw.username.length > 128 || /[\0\r\n]/.test(raw.username)) throw new Error('Invalid SSH username'); + if (raw.authMethod !== 'password' && raw.authMethod !== 'publickey') throw new Error('Invalid authentication method'); + const allowedFields = new Set(['type', 'host', 'port', 'username', 'authMethod', 'password', 'privateKey', 'cols', 'rows', 'term', 'expectedFingerprint']); + if (Object.keys(raw).some((field) => !allowedFields.has(field))) throw new Error('Unsupported connection field'); + const size = normalizeTerminalSize(raw.cols ?? 120, raw.rows ?? 40); + if (!size) throw new Error('Invalid terminal size'); + + const password = typeof raw.password === 'string' ? raw.password : undefined; + const privateKey = typeof raw.privateKey === 'string' ? raw.privateKey : undefined; + if (raw.authMethod === 'password' && (password === undefined || password.length > 4096 || privateKey !== undefined)) throw new Error('Password is required and must be the only credential'); + if (raw.authMethod === 'publickey' && (!privateKey || privateKey.length > 128 * 1024 || !privateKey.includes('BEGIN OPENSSH PRIVATE KEY') || password !== undefined)) throw new Error('An unencrypted OpenSSH private key is required and must be the only credential'); + if (raw.expectedFingerprint !== undefined && (typeof raw.expectedFingerprint !== 'string' || !/^SHA256:[A-Za-z0-9+/]{43}$/.test(raw.expectedFingerprint))) throw new Error('Invalid host key fingerprint'); + const term = typeof raw.term === 'string' && /^[A-Za-z0-9._+-]{1,64}$/.test(raw.term) ? raw.term : 'xterm-256color'; + + return { + type: 'connect', host: raw.host.trim().replace(/^\[|\]$/g, ''), port: raw.port, + username: raw.username, authMethod: raw.authMethod, password, privateKey, + cols: size.cols, rows: size.rows, term, expectedFingerprint: raw.expectedFingerprint as string | undefined, + }; +} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..90f42eb --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,91 @@ +import type { Env } from './types'; +import { SSHSessionDO } from './backend/durable-object'; +import { constantTimeEqual, isSameOrigin } from './backend/security'; +import { httpsRedirect, isProductionHttp, jsonError, secureResponse } from './http-security'; + +export { SSHSessionDO }; + +function clientAddress(request: Request): string { + const value = request.headers.get('CF-Connecting-IP') ?? 'local'; + return /^[0-9A-Fa-f:.]{2,64}$/.test(value) ? value.toLowerCase() : 'unknown'; +} + +async function sessionTicket(request: Request, env: Env): Promise { + if (!isSameOrigin(request)) return jsonError('Invalid request origin', 403); + 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); + if (!Number.isFinite(contentLength) || contentLength < 0 || contentLength > 8192) return jsonError('Request body is too large', 413); + let body: unknown; + try { + const text = await request.text(); + if (new TextEncoder().encode(text).length > 8192) return jsonError('Request body is too large', 413); + body = JSON.parse(text); + } catch { return jsonError('Invalid JSON body', 400); } + if (!body || typeof body !== 'object' || Array.isArray(body)) return jsonError('Invalid JSON body', 400); + const fields = Object.keys(body as Record); + if (fields.some((field) => field !== 'accessToken')) return jsonError('Unsupported request field', 400); + if (!env.ACCESS_TOKEN && env.ALLOW_ANONYMOUS !== 'true') { + return jsonError('Gateway access is not configured', 503); + } + const supplied = (body as Record).accessToken; + if (typeof supplied === 'string' && supplied.length > 4096) return jsonError('Access token is too large', 413); + if (env.ACCESS_TOKEN && (typeof supplied !== 'string' || !constantTimeEqual(supplied, env.ACCESS_TOKEN))) return jsonError('Invalid access token', 401); + const id = env.SSH_SESSIONS.newUniqueId(); + const stub = env.SSH_SESSIONS.get(id); + const response = await stub.fetch(new Request('https://session.internal/ticket', { + method: 'POST', + headers: { 'x-client-ip': clientAddress(request) }, + })); + if (!response.ok) return jsonError('Unable to create a session ticket', 503); + const ticket = await response.json<{ ticket: string; expiresAt: number }>(); + return secureResponse(Response.json({ ...ticket, sessionId: id.toString() }, { headers: { 'Cache-Control': 'no-store' } })); +} + +async function sshUpgrade(request: Request, env: Env): Promise { + if (!isSameOrigin(request)) return jsonError('Invalid WebSocket origin', 403); + if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') return jsonError('WebSocket upgrade required', 426); + const url = new URL(request.url); + const ticket = url.searchParams.get('ticket'); + const sessionId = url.searchParams.get('session'); + if (!ticket || !sessionId) return jsonError('Missing session ticket', 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.set('x-session-ticket', ticket); + headers.set('x-client-ip', clientAddress(request)); + 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; +} + +export default { + async fetch(request: Request, env: Env): Promise { + try { + const url = new URL(request.url); + if (isProductionHttp(request)) { + if (url.pathname.startsWith('/api/')) return jsonError('HTTPS is required', 403); + if (request.method === 'GET' || request.method === 'HEAD') return httpsRedirect(request); + return jsonError('HTTPS is required', 403); + } + if (url.pathname === '/api/health' && request.method === 'GET') { + return secureResponse(Response.json({ status: 'ok', runtime: 'cloudflare-workers', ssh: true }, { headers: { 'Cache-Control': 'no-store' } })); + } + if (url.pathname === '/api/session') { + if (request.method !== 'POST') return jsonError('Method not allowed', 405); + return sessionTicket(request, env); + } + if (url.pathname === '/api/ssh') { + if (request.method !== 'GET') return jsonError('Method not allowed', 405); + return sshUpgrade(request, env); + } + if (url.pathname.startsWith('/api/')) return jsonError('Not found', 404); + if (!env.ASSETS) return jsonError('Static assets binding is not configured', 503); + return secureResponse(await env.ASSETS.fetch(request)); + } catch (error) { + console.error('Worker request failed', error instanceof Error ? error.message : String(error)); + return jsonError('Internal server error', 500); + } + }, +}; diff --git a/tsconfig.frontend.json b/tsconfig.frontend.json new file mode 100644 index 0000000..91bc59a --- /dev/null +++ b/tsconfig.frontend.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["frontend/src/**/*.ts"] +} diff --git a/tsconfig.worker.json b/tsconfig.worker.json new file mode 100644 index 0000000..716d654 --- /dev/null +++ b/tsconfig.worker.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022", "WebWorker"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..673fdfa --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: 'frontend', + base: '/', + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://127.0.0.1:8787', + ws: true, + }, + }, + }, + esbuild: { + // xterm 6 is already optimized; syntax minification can break DECRQM handling. + minifySyntax: false, + }, + build: { + outDir: '../dist', + emptyOutDir: true, + sourcemap: false, + modulePreload: { polyfill: false }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9b7bece --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + environment: 'node', + coverage: { + reporter: ['text', 'html'], + include: ['src/ssh/**/*.ts', 'src/backend/security.ts', 'src/types.ts'], + }, + }, +}); diff --git a/vitest.edge.config.ts b/vitest.edge.config.ts new file mode 100644 index 0000000..b31b9c2 --- /dev/null +++ b/vitest.edge.config.ts @@ -0,0 +1,9 @@ +import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [cloudflareTest({ wrangler: { configPath: './wrangler.toml' } })], + test: { + include: ['tests-edge/**/*.test.ts'], + }, +}); diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..53aa32f --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,26 @@ +name = "cf-workers-webssh" +main = "src/worker.ts" +compatibility_date = "2026-07-01" +workers_dev = true + +[assets] +directory = "./dist" +binding = "ASSETS" +not_found_handling = "single-page-application" +run_worker_first = true + +[vars] +ALLOW_ANONYMOUS = "false" +CONNECT_TIMEOUT_MS = "10000" + +[[durable_objects.bindings]] +name = "SSH_SESSIONS" +class_name = "SSHSessionDO" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["SSHSessionDO"] + +# Recommended production gate. Without it, sessions stay disabled unless +# ALLOW_ANONYMOUS is explicitly changed to "true". Never store it here: +# npx wrangler secret put ACCESS_TOKEN