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:
1 parent
b73ddc6761
commit
de6940ee89
10 files changed
+153
-202
No files matched your search
+1
-3
@@ -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
|
||||
@@ -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
@@ -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">>_</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">
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user