Refactor SSH session management and history tracking

- Removed ACCESS_TOKEN and ALLOW_ANONYMOUS from environment variables and related logic.
- Updated README and documentation to reflect changes in access control and session management.
- Introduced history tracking for SSH connections, allowing automatic saving of successful connections.
- Modified frontend to display connection history instead of saved targets, with appropriate UI updates.
- Refactored backend security logic to remove access token validation.
- Added new history management functions to handle connection entries.
This commit is contained in:
cmliu committed 2026-07-27 21:40:08 +08:00
1 parent b73ddc6761
commit de6940ee89
10 files changed
+153 -202

No files matched your search

+1 -3
View File
@@ -1,4 +1,2 @@
# Copy to .dev.vars for local development. Do not commit real credentials.
ACCESS_TOKEN=replace-with-a-long-random-value
ALLOW_ANONYMOUS=false
# Copy to .dev.vars for local development.
CONNECT_TIMEOUT_MS=10000
+42 -73
View File
@@ -5,7 +5,7 @@
项目不依赖额外的 WebSSH 后端、容器或常驻服务器;前端、会话网关、SSH 客户端实现和静态资源均由同一个 Worker 部署提供。
> [!IMPORTANT]
> 这是一个 SSH 访问网关。部署者能够控制 Worker 代码和运行时配置,请只使用自己信任的部署。生产环境务必设置 `ACCESS_TOKEN` 或使用等效的 Cloudflare Access 保护,不要直接开放匿名访问。
> 这是一个 SSH 访问网关。项目始终允许匿名创建网关会话,不提供内置的访问令牌或用户身份认证。公网部署前务必使用 Cloudflare Access、WAF 与限流策略保护页面和 API,并关闭不需要的 `workers.dev` 公网入口。
## 功能特性
@@ -13,7 +13,8 @@
- 基于 xterm.js 的响应式终端,支持桌面端和移动端、自动缩放、全屏和会话日志。
- 支持密码认证,以及 Ed25519、RSA、ECDSA 的未加密 OpenSSH 私钥认证。
- 首次连接时暂停认证并显示主机 SHA-256 指纹,确认后才会发送 SSH 凭据。
- 可在浏览器本地保存连接配置、Base64 编码的密码和主机指纹,但不保存私钥或网关令牌。
- SSH 连接成功后自动写入浏览器本地“历史记录”;条目统一以 `用户名@主机:端口` 命名,第二行显示最后连接时间。
- 历史记录可恢复连接配置、Base64 编码的密码和主机指纹,但不保存私钥。
- 支持 UTF-8、GB18030、Big5 显示编码、初始命令和无凭据分享链接。
- 提供一次性会话票据、同源检查、HTTPS 强制、安全响应头和公网目标校验。
- 运行时无 SSH 第三方依赖,SSH 数据包、密钥交换、加密、认证和通道逻辑均使用 TypeScript 与 Web Crypto 实现。
@@ -26,7 +27,7 @@
│ WSS:终端输入、输出和控制消息
▼
Cloudflare Worker
│ 同源检查、ACCESS_TOKEN 校验、静态资源
│ 同源检查、会话票据、静态资源
▼
每会话 Durable Object
│ 消耗一次性票据、校验目标地址、运行 SSH 2.0 客户端
@@ -37,13 +38,13 @@ Cloudflare Worker
一次连接的主要流程如下:
1. 浏览器向 `POST /api/session` 提交网关访问令牌。
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 通道之间开始转发终端数据。
7. 认证成功后建立 PTY 和交互式 Shell,浏览器与 SSH 通道之间开始转发终端数据,并将该目标更新到浏览器历史记录。
## 支持范围
@@ -71,13 +72,12 @@ Cloudflare Worker
### 已实现的保护
- 默认关闭:未配置 `ACCESS_TOKEN` 且 `ALLOW_ANONYMOUS` 不为 `true` 时,会话接口返回 `503`。
- `ACCESS_TOKEN` 使用恒定时间比较;一次性票据使用随机密钥和 HMAC-SHA256 签名。
- 一次性票据使用随机密钥和 HMAC-SHA256 签名;会话申请始终匿名,票据本身不代表用户身份认证。
- 会话票据绑定请求端 IP、60 秒过期并在第一次使用尝试时立即销毁,降低重放风险。
- API 和 WebSocket 必须来自页面同源 `Origin`;Cloudflare 边缘上的 HTTP API 请求会被拒绝,页面请求会重定向至 HTTPS。
- 域名解析后逐个检查公网地址,并直接连接已验证 IP,以限制 SSRF 与 DNS 重绑定。
- SSH 主机密钥会验证交换签名并计算 `SHA256:` 指纹;没有固定指纹时,认证会暂停等待用户确认。
- 保存连接配置时,密码会以可逆的 Base64 编码写入浏览器 Local Storage;这不是加密,能访问该站点本地存储的代码可以还原密码。私钥和网关令牌不会写入 Local Storage,任何凭据都不会写入 Durable Object 存储。连接表单在发起授权后立即清空,Worker 在生成认证请求后释放凭据引用。
- SSH 连接成功后,连接配置会自动写入浏览器 Local Storage 的“历史记录”。密码以可逆的 Base64 编码保存,这不是加密,能访问该站点本地存储的代码可以还原密码;私钥不会写入 Local Storage,任何 SSH 凭据都不会写入 Durable Object 存储。凭据输入框在发起授权后立即清空,Worker 在生成认证请求后释放凭据引用。
- 响应设置 CSP、HSTS、`X-Frame-Options`、`X-Content-Type-Options` 等安全头。
### 信任边界
@@ -90,6 +90,16 @@ Cloudflare Worker
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub -E sha256
```
## 历史记录
页面不提供单独的“保存”按钮。只有 SSH 连接成功后,目标才会自动新增或更新到当前浏览器的“历史记录”:
- 名称固定为 `用户名@主机:端口`,例如 `root@ssh.example.com:22`,不再填写自定义连接名称。
- 每个条目的第二行只显示最后连接时间;再次成功连接同一条目时会更新时间。
- 点击历史条目会恢复主机、端口、用户名、认证方式和其他已保存选项,便于随时重新连接。
- 密码以可逆的 Base64 形式保存在当前站点的 Local Storage 中;私钥始终不保存,使用私钥认证时需要重新选择或粘贴。
- 历史记录属于当前浏览器和当前站点来源;清除站点数据、使用隐私浏览窗口或更换域名后无法读取原有记录。
## 部署教程:Wrangler CLI(推荐)
### 1. 准备条件
@@ -144,7 +154,6 @@ not_found_handling = "single-page-application"
run_worker_first = true
[vars]
ALLOW_ANONYMOUS = "false"
CONNECT_TIMEOUT_MS = "10000"
```
@@ -179,33 +188,9 @@ npm run deploy
https://cf-workers-webssh.<your-subdomain>.workers.dev
```
此时尚未设置 `ACCESS_TOKEN`,因此页面和健康接口可以访问,但默认 `ALLOW_ANONYMOUS = "false"` 会阻止创建 SSH 会话。这是安全的初始状态。
部署完成后,页面和会话接口都可以直接访问。项目不会要求配置网关令牌;在公开地址验证 SSH 连接前,请先按后文配置 Cloudflare Access、WAF 与限流规则。
### 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. 验证部署
### 7. 验证部署
先检查健康接口:
@@ -223,11 +208,10 @@ curl https://cf-workers-webssh.<your-subdomain>.workers.dev/api/health
1. 输入 SSH 公网主机、端口和用户名。
2. 选择密码或 SSH 密钥认证并输入凭据。
3. 展开“高级选项”,输入 `ACCESS_TOKEN`。
4. 点击“连接”。第一次连接时,通过可信渠道核对页面显示的 SHA-256 主机指纹。
5. 确认后进入交互式 Shell。若勾选记住指纹,后续连接会在当前浏览器中自动固定并比对。
3. 点击“连接”。第一次连接时,通过可信渠道核对页面显示的 SHA-256 主机指纹。
4. 确认后进入交互式 Shell;连接成功的目标会自动进入“历史记录”。若勾选记住指纹,后续连接会在当前浏览器中自动固定并比对。
### 9. 更新部署
### 8. 更新部署
```bash
git pull --ff-only
@@ -242,25 +226,15 @@ npm run deploy
| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `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]`。
普通变量可以编辑 `wrangler.toml` 后重新部署。会话创建始终匿名,不存在用于开启、关闭或保护会话创建的项目变量。公开网关可能被滥用、产生费用或导致 Cloudflare 账户受限,应在 Cloudflare 边缘配置身份访问策略、WAF、限流和使用监控。
### 匿名模式(不推荐用于公网)
### 从旧版本升级
如果明确接受任何访问者都能借助你的 Worker 发起公网 SSH TCP 连接,可以删除 `ACCESS_TOKEN` 并修改:
```toml
[vars]
ALLOW_ANONYMOUS = "true"
CONNECT_TIMEOUT_MS = "10000"
```
随后重新运行 `npm run deploy`。公开网关可能被滥用、产生费用或导致 Cloudflare 账户被限制;至少应配合 Cloudflare Access、限流策略和使用监控。
旧版本部署中可能仍残留已废弃的网关访问 Secret 或匿名开关。新版本会忽略这些值,它们不会继续提供访问保护;请在 Worker 的 `Settings` -> `Variables and Secrets` 中手动清理对应的旧 Secret 和变量,避免产生仍受保护的错误判断。升级并开放流量前,先确认 Cloudflare Access、WAF、限流规则及所有可访问域名均已正确配置。
## 自定义域名与 Cloudflare Access
@@ -296,12 +270,13 @@ workers_dev = false
可在 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 服务端。
- 使用 WAF 规则限制异常请求、已知恶意来源和不需要的访问区域。
- 对会话申请与 WebSocket 建连路径配置限流,并结合 Worker、Durable Objects 和网络使用量监控设置告警。
- 验证 Access 策略同时覆盖页面、`/api/session` 与 `/api/ssh`;同源检查和一次性票据不能替代用户身份认证。
## 通过 Cloudflare Git 集成部署
@@ -312,10 +287,10 @@ workers_dev = false
3. 项目根目录使用 `/`,Node.js 版本选择 22 或更高。
4. Build command 留空;`wrangler.toml` 中的 `[build]` 会自动执行 `npm run build:web`。
5. Deploy command 设置为 `npx wrangler deploy` 或 `npm run deploy`,两者都会通过同一个 Wrangler 构建钩子生成前端资源。
6. 首次部署完成后,在 Worker 的 `Settings` -> `Variables and Secrets` 中新增加密 Secret:`ACCESS_TOKEN`。
7. 再次触发部署,并按前文方式检查 `/api/health` 与实际 SSH 连接。
6. 首次部署完成后,配置 Cloudflare Access、WAF、限流规则,并确认没有不受保护的公开域名。
7. 按前文方式检查 `/api/health` 与实际 SSH 连接。
Dashboard 的菜单名称可能随 Cloudflare UI 更新而略有变化。关键点是:`wrangler.toml` 必须参与部署,其 `[build]` 配置会在 Wrangler 读取 `dist/` 前生成前端资源;`ACCESS_TOKEN` 必须是运行时 Secret 而不是公开构建变量。
Dashboard 的菜单名称可能随 Cloudflare UI 更新而略有变化。关键点是:`wrangler.toml` 必须参与部署,其 `[build]` 配置会在 Wrangler 读取 `dist/` 前生成前端资源;访问控制必须配置在 Cloudflare 边缘并覆盖所有公开入口。
## 本地开发
@@ -338,12 +313,10 @@ 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 密码或私钥。
`.dev.vars` 已被 `.gitignore` 忽略。不要提交 SSH 密码、私钥或其他真实凭据。
### 启动完整本地环境
@@ -398,7 +371,7 @@ npm run dev:web
├── src/
│ ├── backend/
│ │ ├── durable-object.ts # 会话票据、TCP Socket 与会话生命周期
│ │ ├── security.ts # Token、票据、同源与公网目标校验
│ │ ├── security.ts # 票据、同源与公网目标校验
│ │ └── session.ts # SSH 状态机与浏览器消息桥接
│ ├── ssh/ # SSH 协议、KEX、密码学、认证与通道实现
│ ├── http-security.ts # HTTPS 与安全响应头
@@ -418,20 +391,16 @@ npm run dev:web
| 接口 | 方法 | 用途 |
| --- | --- | --- |
| `/api/health` | `GET` | 返回 Worker 与 SSH 功能健康状态 |
| `/api/session` | `POST` | 校验 `ACCESS_TOKEN` 并创建一次性会话票据 |
| `/api/session` | `POST` | 匿名创建一次性会话票据 |
| `/api/ssh?ticket=...&session=...` | `GET` + WebSocket Upgrade | 进入对应 Durable Object 并建立 SSH 会话 |
`/api/session` 和 `/api/ssh` 都要求请求 `Origin` 与 Worker URL 同源。票据包含敏感授权能力且只能短时使用,不应记录、复用或转发。
`/api/session` 和 `/api/ssh` 都要求请求 `Origin` 与 Worker URL 同源。票据包含敏感授权能力且只能短时使用,不应记录、复用或转发。同源检查只限制浏览器请求来源,不验证用户身份;公网访问控制应由 Cloudflare Access、WAF 和限流策略提供。
## 常见问题
### `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` 轮换。连接发起后令牌输入框会被清空,这是预期行为。
这是项目的预期行为:会话创建始终匿名,一次性票据仅用于把短时请求安全地交给对应 Durable Object,不承担用户身份认证。公网部署必须使用 Cloudflare Access 限制访问者,并配合 WAF、限流和监控降低扫描、滥用与费用风险。
### `403 Invalid request origin` / `Invalid WebSocket origin`
@@ -480,12 +449,12 @@ ssh-keygen -t ed25519 -f webssh_ed25519 -N ""
## 运维建议
- 使用 Cloudflare Access 和 `ACCESS_TOKEN` 双重保护,关闭不需要的 `workers.dev` 公网入口。
- 使用 Cloudflare Access 保护所有公开域名,配置 WAF 与会话接口限流,并关闭不需要的 `workers.dev` 公网入口。
- 为 WebSSH 创建独立、低权限 SSH 账户和密钥;敏感运维使用 `sudo` 审计与最小授权。
- 定期轮换 `ACCESS_TOKEN`,开启 Cloudflare 账户 MFA,并限制部署 API Token 权限。
- 开启 Cloudflare 账户 MFA,定期审查 Access 策略、WAF 与限流规则,并限制部署 API Token 权限。
- 监控 Worker、Durable Objects 和网络使用量。每个活动终端都会占用 Durable Object 与出站 TCP 连接,费用和限制以 Cloudflare 当前套餐为准。
- 更新前执行 `npm run check`,保留已经发布的 Durable Object migrations,并在低峰期部署。
- 不要在 URL、Issue、日志、截图或分享链接中放入密码、私钥、网关令牌或一次性票据。
- 不要在 URL、Issue、日志、截图或分享链接中放入密码、私钥或一次性票据。
## 许可证
+6 -29
View File
@@ -102,31 +102,16 @@
<section class="profiles-section" aria-labelledby="profiles-title">
<div class="section-title-row">
<h2 id="profiles-title" data-i18n-zh="已保存目标" data-i18n-en="Saved targets">已保存目标</h2>
<h2 id="profiles-title" data-i18n-zh="历史记录" data-i18n-en="History">历史记录</h2>
<span id="profile-count" class="count-badge">0</span>
</div>
<div id="profile-list" class="profile-list">
<p class="empty-list" data-i18n-zh="暂无已保存目标" data-i18n-en="No saved targets yet.">暂无已保存目标</p>
<p class="empty-list" data-i18n-zh="暂无历史记录" data-i18n-en="No connection history yet.">暂无历史记录</p>
</div>
</section>
<form id="connection-form" class="connection-form" autocomplete="off" novalidate>
<input id="profile-id" type="hidden" />
<div class="field-grid single">
<label class="field">
<span data-i18n-zh="连接名称" data-i18n-en="Profile name">连接名称</span>
<input
id="profile-name"
name="profileName"
type="text"
maxlength="253"
placeholder="生产环境"
data-i18n-placeholder-zh="生产环境"
data-i18n-placeholder-en="Production"
/>
</label>
</div>
<div class="field-grid host-grid">
<label class="field">
<span data-i18n-zh="主机" data-i18n-en="Host">主机</span>
@@ -161,7 +146,7 @@
<div id="password-field" class="field secret-field">
<label for="password">
<span data-i18n-zh="密码" data-i18n-en="Password">密码</span>
<small data-i18n-zh="保存配置时使用 Base64 编码" data-i18n-en="Base64-encoded when profile is saved">保存配置时使用 Base64 编码</small>
<small data-i18n-zh="连接成功后以 Base64 保存" data-i18n-en="Saved as Base64 after a successful connection">连接成功后以 Base64 保存</small>
</label>
<span class="input-action-wrap">
<input id="password" name="password" type="password" maxlength="4096" autocomplete="current-password" />
@@ -226,13 +211,6 @@
spellcheck="false"
/>
</label>
<label class="field">
<span>
<span data-i18n-zh="网关访问令牌" data-i18n-en="Gateway access token">网关访问令牌</span>
<small data-i18n-zh="可选且永不存储" data-i18n-en="optional, never stored">可选且永不存储</small>
</span>
<input id="access-token" name="accessToken" type="password" maxlength="4096" autocomplete="off" />
</label>
</div>
</details>
@@ -242,7 +220,6 @@
<span class="button-icon" aria-hidden="true">&gt;_</span>
<span>连接</span>
</button>
<button id="save-profile" class="secondary-button" type="button" data-i18n-zh="保存" data-i18n-en="Save">保存</button>
<button
id="share-link"
class="icon-button bordered"
@@ -258,9 +235,9 @@
<p
class="privacy-note"
data-i18n-zh="保存配置时,密码会以可逆的 Base64 编码保存在此浏览器;私钥和网关令牌不会保存。"
data-i18n-en="Saved profiles store passwords in reversible Base64 in this browser; private keys and gateway tokens are not saved."
>保存配置时,密码会以可逆的 Base64 编码保存在此浏览器;私钥和网关令牌不会保存。</p>
data-i18n-zh="连接成功后会自动加入此浏览器的历史记录;密码以可逆的 Base64 编码保存,私钥不会保存。"
data-i18n-en="Successful connections are added to this browser's history automatically; passwords use reversible Base64, while private keys are not saved."
>连接成功后会自动加入此浏览器的历史记录;密码以可逆的 Base64 编码保存,私钥不会保存。</p>
</aside>
<main class="workspace">
+38
View File
@@ -0,0 +1,38 @@
export type HistoryAuthMethod = 'password' | 'publickey';
export interface HistoryEntry {
id: string;
host: string;
port: number;
username: string;
authMethod: HistoryAuthMethod;
passwordBase64?: string;
initialCommand: string;
termType: string;
encoding: string;
fingerprint: string;
updatedAt: number;
}
export function historyKey(host: string, port: number, username: string): string {
return `${username}@${host.toLowerCase()}:${port}`;
}
export function historyLabel(host: string, port: number, username: string): string {
const displayHost = host.includes(':') ? `[${host}]` : host;
return `${username}@${displayHost}:${port}`;
}
export function normalizeHistory(entries: HistoryEntry[]): HistoryEntry[] {
const unique = new Map<string, HistoryEntry>();
for (const entry of [...entries].sort((left, right) => right.updatedAt - left.updatedAt)) {
const key = historyKey(entry.host, entry.port, entry.username);
if (!unique.has(key)) unique.set(key, entry);
}
return [...unique.values()];
}
export function upsertHistory(entries: HistoryEntry[], entry: HistoryEntry): HistoryEntry[] {
const key = historyKey(entry.host, entry.port, entry.username);
return [entry, ...entries.filter((item) => historyKey(item.host, item.port, item.username) !== key)];
}
+62 -71
View File
@@ -1,6 +1,7 @@
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { historyKey, historyLabel, normalizeHistory, upsertHistory, type HistoryEntry } from './history';
import './style.css';
type AuthMethod = 'password' | 'publickey';
@@ -13,20 +14,7 @@ interface LocalizedMessage {
en: string;
}
interface SavedProfile {
id: string;
name: string;
host: string;
port: number;
username: string;
authMethod: AuthMethod;
passwordBase64?: string;
initialCommand: string;
termType: string;
encoding: string;
fingerprint: string;
updatedAt: number;
}
type SavedProfile = HistoryEntry;
interface ConnectionConfig {
type: 'connect';
@@ -89,7 +77,6 @@ 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 LANGUAGE_STORAGE_KEY = 'workers-webssh.language';
const MAX_PROFILES = 30;
const MAX_KEY_BYTES = 65_536;
const MAX_PASSWORD_BASE64_LENGTH = 16_384;
const PING_INTERVAL_MS = 25_000;
@@ -184,8 +171,6 @@ const SERVER_EVENT_MESSAGES: Record<string, Translation> = {
const SERVER_MESSAGE_TRANSLATIONS: Record<string, string> = {
'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 端口无效',
@@ -236,7 +221,6 @@ const ui = {
profileCount: element<HTMLElement>('profile-count'),
form: element<HTMLFormElement>('connection-form'),
profileId: element<HTMLInputElement>('profile-id'),
profileName: element<HTMLInputElement>('profile-name'),
host: element<HTMLInputElement>('host'),
port: element<HTMLInputElement>('port'),
username: element<HTMLInputElement>('username'),
@@ -251,10 +235,8 @@ const ui = {
termType: element<HTMLSelectElement>('term-type'),
encoding: element<HTMLSelectElement>('encoding'),
fingerprint: element<HTMLInputElement>('fingerprint'),
accessToken: element<HTMLInputElement>('access-token'),
formError: element<HTMLElement>('form-error'),
connect: element<HTMLButtonElement>('connect-button'),
saveProfile: element<HTMLButtonElement>('save-profile'),
shareLink: element<HTMLButtonElement>('share-link'),
globalStatus: element<HTMLElement>('global-status'),
globalStatusLabel: element<HTMLElement>('global-status-label'),
@@ -361,6 +343,7 @@ let currentSessionSubtitle: LocalizedMessage = { zh: '选择目标并连接', en
let currentEventMessage: LocalizedMessage = { zh: 'Worker 运行时待命', en: 'Worker runtime standing by' };
let currentFormError: LocalizedMessage | null = null;
let passwordDirty = false;
let pendingHistory: { generation: number; profile: SavedProfile } | null = null;
const terminal = new Terminal({
allowProposedApi: false,
@@ -411,8 +394,6 @@ function isSavedProfile(value: unknown): value is SavedProfile {
const item = value as Partial<SavedProfile>;
return (
typeof item.id === 'string' &&
typeof item.name === 'string' &&
item.name.length >= 1 && item.name.length <= 253 &&
typeof item.host === 'string' &&
item.host.length >= 1 && item.host.length <= 253 && !/[\s/?#]/.test(item.host) &&
typeof item.port === 'number' &&
@@ -435,7 +416,10 @@ function isSavedProfile(value: unknown): value is SavedProfile {
['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'
typeof item.updatedAt === 'number' &&
Number.isFinite(item.updatedAt) &&
item.updatedAt >= 0 &&
item.updatedAt <= 8_640_000_000_000_000
);
}
@@ -443,7 +427,6 @@ function sanitizeSavedProfile(value: unknown): SavedProfile | null {
if (!isSavedProfile(value)) return null;
const profile: SavedProfile = {
id: value.id,
name: value.name,
host: value.host,
port: value.port,
username: value.username,
@@ -469,9 +452,11 @@ function sanitizeSavedProfile(value: unknown): SavedProfile | null {
function loadProfiles(): SavedProfile[] {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(PROFILE_STORAGE_KEY) ?? '[]');
return Array.isArray(parsed)
? parsed.map(sanitizeSavedProfile).filter((profile): profile is SavedProfile => profile !== null).slice(0, MAX_PROFILES)
: [];
if (!Array.isArray(parsed)) return [];
const sanitized = parsed
.map(sanitizeSavedProfile)
.filter((profile): profile is SavedProfile => profile !== null);
return normalizeHistory(sanitized);
} catch {
return [];
}
@@ -491,7 +476,7 @@ function loadHostKeys(): Record<string, string> {
function persistProfiles(): boolean {
try {
localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(profiles.slice(0, MAX_PROFILES)));
localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(profiles));
return true;
} catch {
toast(bilingual('此浏览器无法保存连接配置。', 'Profiles could not be saved in this browser.'), 'error');
@@ -532,7 +517,6 @@ function clearPrivateKeyFields(): void {
function clearCredentials(): void {
resetPasswordField();
clearPrivateKeyFields();
ui.accessToken.value = '';
}
function encodePassword(password: string): string {
@@ -555,7 +539,11 @@ function normalizeHost(host: string): string {
}
function targetKey(host = normalizeHost(ui.host.value), port = Number(ui.port.value), username = ui.username.value.trim()): string {
return `${username}@${host.toLowerCase()}:${port}`;
return historyKey(host, port, username);
}
function targetLabel(host: string, port: number, username: string): string {
return historyLabel(host, port, username);
}
function applyFormDefaults(): void {
@@ -569,9 +557,9 @@ function readProfileFromForm(): SavedProfile {
const host = normalizeHost(ui.host.value);
const port = Number(ui.port.value);
const username = ui.username.value.trim();
const existing = profiles.find((item) => targetKey(item.host, item.port, item.username) === targetKey(host, port, username));
const profile: SavedProfile = {
id: ui.profileId.value || crypto.randomUUID(),
name: ui.profileName.value.trim() || host,
id: existing?.id ?? crypto.randomUUID(),
host,
port,
username,
@@ -584,8 +572,8 @@ function readProfileFromForm(): SavedProfile {
};
if (profile.authMethod === 'password' && ui.password.value) {
profile.passwordBase64 = encodePassword(ui.password.value);
} else if (profile.authMethod === 'password' && ui.profileId.value && !passwordDirty) {
profile.passwordBase64 = profiles.find((item) => item.id === ui.profileId.value)?.passwordBase64;
} else if (profile.authMethod === 'password' && existing && !passwordDirty) {
profile.passwordBase64 = existing.passwordBase64;
}
return profile;
}
@@ -628,7 +616,6 @@ function validateConnectForm(): string | null {
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;
@@ -641,7 +628,7 @@ function applyProfile(profile: SavedProfile): void {
try {
ui.password.value = decodePassword(profile.passwordBase64);
} catch {
toast(bilingual('已保存的密码无法解码,请重新输入并保存。', 'The saved password could not be decoded. Enter and save it again.'), 'error');
toast(bilingual('历史记录中的密码无法解码,请重新输入并连接。', 'The password in history could not be decoded. Enter it again and connect.'), 'error');
}
}
passwordDirty = false;
@@ -661,34 +648,28 @@ function clearForm(): void {
currentFormError = null;
setAuthMethod('password');
renderProfiles();
ui.profileName.focus();
ui.host.focus();
}
function saveCurrentProfile(): void {
const error = validateProfileFields();
if (error) {
showFormError(error);
return;
}
let profile: SavedProfile;
try {
profile = readProfileFromForm();
} catch (profileError) {
showFormError(profileError instanceof Error ? profileError.message : String(profileError));
return;
}
if (!ui.profileName.value.trim()) ui.profileName.value = profile.name;
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;
function saveConnectedProfile(): void {
if (!pendingHistory || pendingHistory.generation !== connectGeneration) return;
const profile = pendingHistory.profile;
pendingHistory = null;
const key = targetKey(profile.host, profile.port, profile.username);
const rememberedFingerprint = profile.fingerprint || hostKeys[key] || '';
const saved: SavedProfile = {
...profile,
fingerprint: rememberedFingerprint,
updatedAt: Date.now(),
};
profiles = upsertHistory(profiles, saved);
ui.profileId.value = saved.id;
if (!persistProfiles()) return;
passwordDirty = false;
renderProfiles();
toast(profile.passwordBase64
? bilingual('连接配置和 Base64 编码的密码已保存在此浏览器。', 'Connection profile and Base64-encoded password saved in this browser.')
: bilingual('连接配置已保存。', 'Connection profile saved.'));
toast(saved.passwordBase64
? bilingual('连接已加入历史记录,密码以 Base64 保存在此浏览器。', 'Connection added to history; the password is stored as Base64 in this browser.')
: bilingual('连接已加入此浏览器的历史记录。', 'Connection added to this browser\'s history.'));
}
function deleteProfile(id: string): void {
@@ -704,7 +685,7 @@ function renderProfiles(): void {
if (profiles.length === 0) {
const empty = document.createElement('p');
empty.className = 'empty-list';
empty.textContent = bilingual('暂无已保存目标', 'No saved targets yet.');
empty.textContent = bilingual('暂无历史记录', 'No connection history yet.');
ui.profileList.append(empty);
return;
}
@@ -719,21 +700,25 @@ function renderProfiles(): void {
main.dataset.profileId = profile.id;
const avatar = document.createElement('span');
avatar.className = 'profile-avatar';
avatar.textContent = profile.name.slice(0, 2).toUpperCase();
avatar.textContent = profile.username.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);
const label = targetLabel(profile.host, profile.port, profile.username);
title.textContent = label;
const lastConnected = document.createElement('time');
const connectedAt = new Date(profile.updatedAt);
lastConnected.dateTime = connectedAt.toISOString();
const formattedTime = new Intl.DateTimeFormat(currentLanguage, { dateStyle: 'medium', timeStyle: 'short' }).format(connectedAt);
lastConnected.textContent = bilingual(`最后连接:${formattedTime}`, `Last connected: ${formattedTime}`);
copy.append(title, lastConnected);
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.setAttribute('aria-label', bilingual(`删除 ${label}`, `Delete ${label}`));
remove.textContent = '\u00d7';
card.append(main, remove);
ui.profileList.append(card);
@@ -856,6 +841,7 @@ function stopTimers(): void {
function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive shell ready')): void {
if (connectionState === 'connected') return;
setState('connected');
saveConnectedProfile();
startTimers();
currentSessionSubtitle = messageTranslation(message);
ui.sessionSubtitle.textContent = message;
@@ -944,6 +930,7 @@ function handleServerMessage(message: ServerMessage): void {
}
if (type === 'error') {
const text = bilingualServerMessage(message.message, message.event, 'The SSH session failed.', 'SSH 错误');
pendingHistory = null;
clearHostKeyPrompt();
event(text, message.event ?? 'error', true);
showFormError(text);
@@ -984,11 +971,11 @@ function sendTerminalData(data: string): void {
socket.send(JSON.stringify({ type: 'input', data }));
}
async function issueTicket(accessToken: string, signal: AbortSignal): Promise<{ ticket: string; sessionId: string }> {
async function issueTicket(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 }),
body: JSON.stringify({}),
signal,
});
let payload: { ticket?: string; sessionId?: string; error?: string } = {};
@@ -1051,12 +1038,13 @@ async function connect(): Promise<void> {
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);
const historyProfile = readProfileFromForm();
pendingHistory = { generation, profile: historyProfile };
const ticketRequest = issueTicket(abortController.signal);
clearCredentials();
const { ticket, sessionId } = await ticketRequest;
if (authorizationAbort === abortController) authorizationAbort = null;
@@ -1099,6 +1087,7 @@ async function connect(): Promise<void> {
});
activeSocket.addEventListener('error', () => {
if (socket !== activeSocket) return;
pendingHistory = null;
clearHostKeyPrompt();
event(bilingual('WebSocket 传输错误。', 'WebSocket transport error.'), 'transport', true);
setState('error');
@@ -1106,6 +1095,7 @@ async function connect(): Promise<void> {
activeSocket.addEventListener('close', (closeEvent) => {
if (socket !== activeSocket) return;
socket = null;
pendingHistory = null;
currentExpectedFingerprint = '';
stopTimers();
clearHostKeyPrompt();
@@ -1125,6 +1115,7 @@ async function connect(): Promise<void> {
} catch (error) {
if (authorizationAbort === abortController) authorizationAbort = null;
if (generation !== connectGeneration) return;
pendingHistory = null;
clearCredentials();
clearHostKeyPrompt();
const message = error instanceof DOMException && error.name === 'AbortError'
@@ -1141,6 +1132,7 @@ async function connect(): Promise<void> {
function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected by user')): void {
connectGeneration++;
pendingHistory = null;
authorizationAbort?.abort();
authorizationAbort = null;
const activeSocket = socket;
@@ -1293,7 +1285,6 @@ ui.form.addEventListener('submit', (formEvent) => {
void connect();
});
ui.newProfile.addEventListener('click', clearForm);
ui.saveProfile.addEventListener('click', saveCurrentProfile);
ui.shareLink.addEventListener('click', copySafeLink);
ui.password.addEventListener('input', () => { passwordDirty = true; });
ui.revealPassword.addEventListener('click', () => {
+2 -2
View File
@@ -511,7 +511,7 @@ button:disabled {
}
.profile-copy strong,
.profile-copy span {
.profile-copy time {
display: block;
overflow: hidden;
text-overflow: ellipsis;
@@ -523,7 +523,7 @@ button:disabled {
font-size: 12px;
}
.profile-copy span,
.profile-copy time,
.profile-meta {
color: var(--muted);
font-family: var(--mono);
-9
View File
@@ -22,15 +22,6 @@ function fromBase64Url(value: string): Uint8Array {
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 };
-2
View File
@@ -49,8 +49,6 @@ export interface TerminalSize {
export interface Env {
SSH_SESSIONS: DurableObjectNamespace;
ASSETS?: Fetcher;
ACCESS_TOKEN?: string;
ALLOW_ANONYMOUS?: string;
CONNECT_TIMEOUT_MS?: string;
}
+2 -8
View File
@@ -1,6 +1,6 @@
import type { Env } from './types';
import { SSHSessionDO } from './backend/durable-object';
import { constantTimeEqual, isSameOrigin } from './backend/security';
import { isSameOrigin } from './backend/security';
import { httpsRedirect, isProductionHttp, jsonError, secureResponse } from './http-security';
export { SSHSessionDO };
@@ -23,13 +23,7 @@ async function sessionTicket(request: Request, env: Env): Promise<Response> {
} 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<string, unknown>);
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<string, unknown>).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);
if (fields.length > 0) return jsonError('Unsupported request field', 400);
const id = env.SSH_SESSIONS.newUniqueId();
const stub = env.SSH_SESSIONS.get(id);
const response = await stub.fetch(new Request('https://session.internal/ticket', {
-5
View File
@@ -13,7 +13,6 @@ not_found_handling = "single-page-application"
run_worker_first = true
[vars]
ALLOW_ANONYMOUS = "true"
CONNECT_TIMEOUT_MS = "10000"
[[durable_objects.bindings]]
@@ -23,7 +22,3 @@ 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