统一 SSH 工作台与主界面视觉,隔离主题适配层
This commit is contained in:
1 parent
67c325aa12
commit
68e0ac1588
58 files changed
+5801
-117
No files matched your search
@@ -4,6 +4,10 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- docs/**
|
||||
- .github/workflows/docs.yml
|
||||
- scripts/prepare-pages-project.mjs
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -21,6 +25,12 @@ jobs:
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
ACCESS_TEAM_DOMAIN: ${{ secrets.ACCESS_TEAM_DOMAIN }}
|
||||
ACCESS_AUD: ${{ secrets.ACCESS_AUD }}
|
||||
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
||||
WORKER_NAME: ${{ vars.WORKER_NAME }}
|
||||
D1_DATABASE_NAME: ${{ vars.D1_DATABASE_NAME }}
|
||||
CUSTOM_DOMAIN: ${{ vars.CUSTOM_DOMAIN }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -37,8 +47,11 @@ jobs:
|
||||
- name: Check project
|
||||
run: npm run check
|
||||
|
||||
- name: Apply D1 migrations
|
||||
run: npx wrangler d1 migrations apply DB --remote
|
||||
- name: Prepare Cloudflare resources
|
||||
run: npm run prepare:deploy
|
||||
|
||||
- name: Deploy Worker
|
||||
run: npm run deploy
|
||||
- name: Sync Worker secrets
|
||||
run: npm run deploy:secrets
|
||||
|
||||
- name: Migrate database and deploy Worker
|
||||
run: npm run deploy:worker
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Deploy docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- docs/**
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- scripts/prepare-pages-project.mjs
|
||||
- .github/workflows/docs.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docs-pages-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy VitePress to Cloudflare Pages
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
DOCS_PROJECT_NAME: ${{ vars.DOCS_PROJECT_NAME }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.12.0
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build and deploy documentation
|
||||
run: npm run deploy:docs
|
||||
@@ -1,6 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
wrangler.deploy.toml
|
||||
coverage/
|
||||
*.log
|
||||
.dev.vars
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-09-20",
|
||||
"macrostructure": "Workbench",
|
||||
"theme": "Coral",
|
||||
"enrichment": "real product capture",
|
||||
"brief": "EdgeSSH VitePress deployment documentation"
|
||||
}
|
||||
]
|
||||
+70
-45
@@ -1,5 +1,7 @@
|
||||
# EdgeSSH 第一版:部署与验收
|
||||
|
||||
面向部署者的最新分步教程、截图占位与故障排查统一维护在 [EdgeSSH 文档网站](https://edgessh-docs.pages.dev/deploy/actions)。本文件保留架构边界和维护者验收记录。
|
||||
|
||||
## 架构与边界
|
||||
|
||||
- 单管理员,无本地用户名、密码、注册、验证码或用户表。身份认证由 Cloudflare Zero Trust Access 完成。
|
||||
@@ -15,29 +17,49 @@
|
||||
|
||||
| 名称 | 配置位置 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `DB` | `wrangler.toml` 的 D1 binding | 主机资料数据库,优先复用已有资源 |
|
||||
| `DB` | workflow 生成的 D1 binding | 主机资料数据库,首次自动创建、后续按名称复用 |
|
||||
| `SSH_SESSIONS` | `wrangler.toml` 的 Durable Object binding | SSH 会话隔离,保留现有类名及迁移历史 |
|
||||
| `ASSETS` | `wrangler.toml` 的静态资源 binding | 前端构建产物,不需要手动创建 |
|
||||
| `ACCESS_TEAM_DOMAIN` | Cloudflare Worker Secret | 团队域名,例如 `my-team.cloudflareaccess.com`,不含协议或路径 |
|
||||
| `ACCESS_AUD` | Cloudflare Worker Secret | 此 Access 应用的 Application Audience (AUD) Tag |
|
||||
| `ENCRYPTION_KEY` | Cloudflare Worker Secret | 32 字节安全随机数的标准 Base64 |
|
||||
| `ACCESS_TEAM_DOMAIN` | GitHub Actions Secret,同步为 Worker Secret | 团队域名,例如 `my-team.cloudflareaccess.com`,不含协议或路径 |
|
||||
| `ACCESS_AUD` | GitHub Actions Secret,同步为 Worker Secret | 此 Access 应用的 Application Audience (AUD) Tag |
|
||||
| `ENCRYPTION_KEY` | GitHub Actions Secret,同步为 Worker Secret | 32 字节安全随机数的标准 Base64 |
|
||||
| `CONNECT_TIMEOUT_MS` | 可选普通变量 | 建连超时;仓库默认 `10000`,通常不要额外配置 |
|
||||
|
||||
`DB`、`SSH_SESSIONS` 与 `ASSETS` 是绑定,不是环境变量。生产环境实际需要手动填写的运行时值只有三个 Worker Secret。
|
||||
`DB`、`SSH_SESSIONS` 与 `ASSETS` 是绑定,不是环境变量。三项运行时值由 GitHub Actions 在部署时同步为 Worker Secret,无需进入 Worker 控制台重复填写。
|
||||
|
||||
## Cloudflare 控制台配置
|
||||
### 用户填写总览
|
||||
|
||||
### 1. 创建 D1 数据库
|
||||
使用自定义域名完成正式部署时,一共填写 **6 个值**:
|
||||
|
||||
1. 打开 Cloudflare Dashboard 的 D1 页面;新版界面通常位于 `Storage & databases > D1`,菜单名称可能随控制台更新而变化。
|
||||
2. 复用本项目已有数据库,或创建一个新的数据库;不要为每次部署重复创建。
|
||||
3. 将数据库名称和 ID 写入 `wrangler.toml` 的 `[[d1_databases]]`,binding 保持为 `DB`。
|
||||
4. 数据表不需要在控制台手动建立。GitHub Actions 会执行远程 migration,只添加项目所需 schema,不清空已有数据。
|
||||
| 名称 | 填写位置 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | GitHub Actions Variable | Cloudflare 账户概览或 Worker 概览 |
|
||||
| `CUSTOM_DOMAIN` | GitHub Actions Variable | 已接入当前 Cloudflare 账户的自定义域名 |
|
||||
| `CLOUDFLARE_API_TOKEN` | GitHub Actions Secret | Cloudflare API Tokens 页面创建 |
|
||||
| `ACCESS_TEAM_DOMAIN` | GitHub Actions Secret | Zero Trust Settings 中的 Team Domain |
|
||||
| `ACCESS_AUD` | GitHub Actions Secret | Self-hosted Access 应用的 AUD Tag |
|
||||
| `ENCRYPTION_KEY` | GitHub Actions Secret | 本机或可信密码管理器生成的 32 字节随机数 Base64 |
|
||||
|
||||
以上 6 项是正式部署的完整必填清单。`WORKER_NAME` 和 `D1_DATABASE_NAME` 是可选的 GitHub Actions Variable,默认分别为 `edgessh` 和 `<WORKER_NAME>-accounts`。D1 数据库、数据库 ID、数据表、Worker 及 Custom Domain route 均由 workflow 创建或更新,不要求用户编辑 `wrangler.toml`。
|
||||
|
||||
## 准备 Cloudflare 身份与凭据
|
||||
|
||||
Cloudflare 控制台只用于创建 API Token 与 Zero Trust Access 应用。不要在控制台手工创建 D1、部署 Worker 或维护另一套 Worker Secret。
|
||||
|
||||
### 1. D1 自动初始化
|
||||
|
||||
无需在控制台创建 D1,也不要修改 `wrangler.toml` 中的占位 ID。workflow 会使用 `D1_DATABASE_NAME`,或默认的 `<WORKER_NAME>-accounts`,在目标账户中按精确名称查找数据库:
|
||||
|
||||
1. 已存在同名数据库时,读取其 ID 并继续复用。
|
||||
2. 不存在时,通过 Cloudflare API 创建一次,再把新 ID 写入 runner 内的临时 Wrangler 配置。
|
||||
3. 使用该临时配置执行远程 migration,只添加项目所需 schema,不清空已有数据。
|
||||
|
||||
数据库 ID 不会写回仓库。改变 `D1_DATABASE_NAME` 会改用或创建另一个数据库,因此日常部署应保持名称稳定。
|
||||
|
||||
### 2. 配置 Zero Trust Access
|
||||
|
||||
1. 进入 `Zero Trust > Access > Applications`,添加一个 `Self-hosted` 应用。
|
||||
2. 应用域名填写最终访问 EdgeSSH 的自定义域名,并保证该域名与 `wrangler.toml` 的 route 一致。
|
||||
2. 应用域名填写最终访问 EdgeSSH 的自定义域名,并保证该域名与 GitHub Actions 的 `CUSTOM_DOMAIN` Variable 一致。
|
||||
3. 添加 Allow 策略,只包含管理员的明确邮箱或身份。不要使用 `Everyone`、`Bypass` 或允许整个邮箱域的宽泛规则。
|
||||
4. 在 Zero Trust 的团队设置中找到 Team Domain,保存不带 `https://` 和路径的域名,例如 `my-team.cloudflareaccess.com`。
|
||||
5. 在 Access 应用详情中复制 Application Audience (AUD) Tag。不要把应用 ID、Client ID 或策略 ID 当作 AUD。
|
||||
@@ -59,47 +81,59 @@ Cloudflare 账户 ID 可在 Dashboard 的账户概览或 Worker 概览中复制
|
||||
| 名称 | GitHub 类型 | 值 |
|
||||
| --- | --- | --- |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Variable | Cloudflare 账户 ID |
|
||||
| `CUSTOM_DOMAIN` | Variable | 自定义域名的纯主机名 |
|
||||
| `CLOUDFLARE_API_TOKEN` | Secret | 上一步创建的最小权限 API Token |
|
||||
| `ACCESS_TEAM_DOMAIN` | Secret | Zero Trust Team Domain,不带协议或路径 |
|
||||
| `ACCESS_AUD` | Secret | Self-hosted Access 应用的 AUD Tag |
|
||||
| `ENCRYPTION_KEY` | Secret | 固定的 32 字节安全随机数 Base64 |
|
||||
|
||||
进入 `Actions > Deploy > Run workflow` 完成首次部署。workflow 会安装依赖、检查项目、迁移 D1 并创建或更新 Worker。首次运行时尚未配置运行时 Secret,受保护 API 暂时返回 503 属于预期现象。
|
||||
可选项放在必填项之后,第一次部署通常不要填写:
|
||||
|
||||
### 5. 在 Worker 界面添加运行时 Secret
|
||||
| 名称 | GitHub 类型 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `WORKER_NAME` | 可选 Variable | `edgessh` |
|
||||
| `D1_DATABASE_NAME` | 可选 Variable | `<WORKER_NAME>-accounts` |
|
||||
|
||||
首次部署完成后,进入 `Workers & Pages > edgessh > Settings > Variables and Secrets`,依次添加以下三项,并将类型选择为 **Secret**:
|
||||
六项必填配置全部保存后,进入 `Actions > Deploy > Run workflow` 完成首次部署。workflow 会安装依赖、检查项目、创建或复用 D1、同步运行时 Secret、执行 migration,并创建或更新 Worker 与自定义域名。
|
||||
|
||||
### 5. 生成并保存运行时 Secret
|
||||
|
||||
在首次部署前,将以下三项添加到 GitHub Actions 的 **Secrets**,不要添加到 Variables:
|
||||
|
||||
| 名称 | 填写内容 |
|
||||
| --- | --- |
|
||||
| `ACCESS_TEAM_DOMAIN` | Zero Trust Team Domain,不带协议和路径 |
|
||||
| `ACCESS_AUD` | Access 应用详情中的 Application Audience (AUD) Tag |
|
||||
| `ENCRYPTION_KEY` | 32 字节安全随机数的标准 Base64,只在首次部署时生成 |
|
||||
| `ENCRYPTION_KEY` | 32 字节安全随机数的标准 Base64,只在首次部署前生成 |
|
||||
|
||||
保存后按控制台提示部署新版本。不要在 GitHub Actions 中重复保存这三项,也不要为 `DB`、`SSH_SESSIONS` 或 `ASSETS` 创建同名变量。`CONNECT_TIMEOUT_MS` 已由 `wrangler.toml` 设置为 `10000`,只有确实需要调整 2,000 至 30,000 毫秒的建连超时时才在配置中修改。
|
||||
workflow 会校验三项 Secret 是否存在,并通过标准输入调用 Wrangler 的批量 Secret API;Secret 不会进入命令参数、临时配置或日志。不要为 `DB`、`SSH_SESSIONS` 或 `ASSETS` 创建同名变量。`CONNECT_TIMEOUT_MS` 已由 `wrangler.toml` 设置为 `10000`,只有确实需要调整 2,000 至 30,000 毫秒的建连超时时才在配置中修改。
|
||||
|
||||
`ENCRYPTION_KEY` 应由可信的密码管理器或本机安全随机数工具生成,解码后必须正好为 32 字节。不要使用普通密码、UUID、示例值或在线随机字符串网页。密钥丢失将无法解密现有资料;当前版本不支持直接轮换,后续部署必须复用原值。
|
||||
|
||||
可在本机运行 `node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"` 生成一次;只把输出保存到 Secret,命令本身不会把密钥写入项目文件。
|
||||
|
||||
## 部署顺序
|
||||
|
||||
1. 检查 Cloudflare 已有 Worker、D1 与 Access 应用,确认资源属于本项目,避免覆盖其他项目。
|
||||
2. 配置 D1、Access 与最小权限 API Token,再将两个部署值保存到 GitHub Actions。
|
||||
3. 手动运行一次 `Deploy` workflow,让 GitHub Actions 自动迁移 D1 并创建或更新 Worker。
|
||||
4. 在 Worker 的 `Variables and Secrets` 界面添加三个运行时 Secret,保存并部署。
|
||||
5. 以后只需推送到 `main`;workflow 会复用原有资源和 Secret 自动发布,不需要在本地执行 Wrangler 部署命令。
|
||||
6. 通过实际 Access 入口验收。所有账号、主机与连接 API 均需 Access 保护;缺少 Access Secret 时会返回 503,不能操作主机或连接 SSH。
|
||||
1. 确定自定义域名;如需覆盖默认值,再确定稳定的 Worker 名称与 D1 名称。同一 Cloudflare 账户内不要与无关项目重名。
|
||||
2. 配置 Access 与最小权限 API Token,再将部署凭据、三项运行时 Secret 和可选名称统一保存到 GitHub Actions。
|
||||
3. 手动运行一次 `Deploy` workflow,让 GitHub Actions 自动创建或复用 D1、同步 Worker Secret、执行 migration,并创建或更新 Worker。
|
||||
4. 以后只需推送到 `main`;workflow 会复用现有资源,并以 GitHub Actions 中的固定 Secret 自动发布,不需要再进入 Worker 控制台或在本地执行 Wrangler 命令。
|
||||
5. 通过实际 Access 入口验收。所有账号、主机与连接 API 均需 Access 保护;缺少 Access Secret 时部署会直接失败,不会发布一个待补配置的版本。
|
||||
|
||||
密钥丢失将无法解密现有资料。更换密钥必须设计旧密钥解密、新密钥重加密的迁移,不可直接覆盖 Secret。当前版本不提供自动轮换。更换 Access 团队或身份导致 `sub` 改变时,也需要显式的数据迁移。
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
`.github/workflows/deploy.yml` 在推送到 `main` 或手动触发时依次执行安装、项目检查、远程 D1 迁移与 Worker 部署。生产部署使用并发锁串行执行,避免迁移和 Worker 版本交错。
|
||||
`.github/workflows/deploy.yml` 在推送到 `main` 或手动触发时依次执行安装、项目检查、D1 幂等初始化、远程 migration 与 Worker 部署。生产部署使用并发锁串行执行,避免初始化、迁移和 Worker 版本交错。
|
||||
|
||||
仓库只需配置两个 Actions 值:
|
||||
`Deploy` workflow 使用“用户填写总览”中的 6 项必填配置。资源名称仍是可选 Variable:
|
||||
|
||||
| 名称 | 配置位置 | 要求 |
|
||||
| --- | --- | --- |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Actions Variable | 目标 Cloudflare 账户 ID |
|
||||
| `CLOUDFLARE_API_TOKEN` | Actions Secret | 限定到目标账户,并具备 Workers 部署与 D1 迁移权限 |
|
||||
| 名称 | 默认值 |
|
||||
| --- | --- |
|
||||
| `WORKER_NAME` | `edgessh` |
|
||||
| `D1_DATABASE_NAME` | `<WORKER_NAME>-accounts` |
|
||||
|
||||
三个运行时 Worker Secret 不进入 GitHub Actions,统一在 Cloudflare Worker 的 `Variables and Secrets` 界面管理;日常自动部署只复用 Cloudflare 中已有值。不要把本地 `.dev.vars` 或 `wrangler secret put` 作为普通用户的生产配置流程。
|
||||
三个运行时 Secret 只保存在 GitHub Actions Secrets 与 Cloudflare Worker Secrets 中。workflow 每次部署都会从 Actions 环境读取并幂等同步,用户无需打开 Cloudflare Worker 的 `Variables and Secrets` 页面;不要把本地 `.dev.vars` 或手动 `wrangler secret put` 作为普通用户的生产配置流程。
|
||||
|
||||
## 验收清单(5.6 SOL 执行)
|
||||
|
||||
@@ -123,19 +157,10 @@ Cloudflare 账户 ID 可在 Dashboard 的账户概览或 Worker 概览中复制
|
||||
- 首页为中文;原 SSH 工作台保留中英文切换。
|
||||
- 国旗使用本地 `flag-icons` SVG(MIT,许可证随静态资源部署),在 Windows 上也显示实际旗帜,不依赖 emoji 字体。
|
||||
|
||||
## 2026-09-19 线上部署记录
|
||||
## 部署后验收
|
||||
|
||||
- 正式入口:`https://ssh.dltwcnm.ccwu.cc`
|
||||
- D1:`edgessh-accounts`(`2c1b7a95-a5d6-4a72-a8d2-4132b74aa268`)
|
||||
- D1 schema:`hosts`、`d1_migrations`;记录数分别为 0、1
|
||||
- 活动版本:由 GitHub Actions 的 `Deploy` workflow 自动发布,当前状态以 Actions 运行记录与 Cloudflare 控制台为准
|
||||
- Secret 名称:`ENCRYPTION_KEY`、`ACCESS_TEAM_DOMAIN`、`ACCESS_AUD`;本次部署未读取或覆盖任何运行时 Secret
|
||||
- 无会话 Smoke:正式入口 `/` 与 `/api/auth/me` 均返回 Access 302,TLS 正常;未记录重定向地址、Team Domain、AUD、JWT、Cookie 或 Secret 值
|
||||
- 诊断入口:`workers.dev` 已开启,仅额外公开固定 `8.8.8.8` 的定位链路检查;账号、主机、凭据与 SSH 等接口仍必须通过 Access JWT 校验
|
||||
- 待验收:需要用户登录 Access 后验收账号 API,并使用真实授权 SSH 目标验收终端、SFTP 与进程面板
|
||||
|
||||
## 自定义域名
|
||||
|
||||
- 正式入口:`https://ssh.dltwcnm.ccwu.cc`
|
||||
- `workers.dev` 作为诊断入口保留;Worker 仍在应用层强制校验所有生产账号与连接 API,不允许绕过自定义域名上的 Access 策略。
|
||||
- Access 应用、策略与三个运行时 Secret 均由用户在 Cloudflare 控制台维护;GitHub Actions 只负责迁移与部署代码。
|
||||
- 仓库中的 `wrangler.toml` 只包含 D1 占位 ID,不包含维护者账户的资源 ID 或固定自定义域名。
|
||||
- workflow 按 `D1_DATABASE_NAME` 在部署者自己的账户内查找或创建 D1,把真实 ID 仅写入 runner 内已忽略的 `wrangler.deploy.toml`,再执行 migration 与 Worker 部署。
|
||||
- workflow 使用必填的 `CUSTOM_DOMAIN`,为部署者自己的域名创建或更新 Custom Domain。
|
||||
- 首次部署后确认 D1 中存在 `hosts` 与 `d1_migrations` 表,再通过 Access 登录,完成主机新增、刷新后恢复、编辑和删除验收。
|
||||
- 账号、主机、凭据与 SSH 接口始终校验 Access JWT;`workers.dev` 不能绕过应用层认证。
|
||||
@@ -16,7 +16,7 @@
|
||||
<img alt="Apache 2.0" src="https://img.shields.io/badge/License-Apache%202.0-546E7A?style=flat-square">
|
||||
</p>
|
||||
|
||||
[功能特性](#功能特性) · [界面预览](#界面预览) · [快速开始](#快速开始) · [本地开发](#本地开发) · [安全与限制](#安全与限制)
|
||||
[文档网站](https://edgessh-docs.pages.dev) · [Actions 部署](https://edgessh-docs.pages.dev/deploy/actions) · [功能特性](#功能特性) · [界面预览](#界面预览) · [本地开发](#本地开发) · [安全与限制](#安全与限制)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,18 @@
|
||||
|
||||
</div>
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<strong>EdgeChat</strong><br>
|
||||
<sub>基于 Cloudflare 的开源自部署团队聊天系统</sub>
|
||||
</td>
|
||||
<td align="right">
|
||||
<a href="https://github.com/aozorae/Edgechat"><strong>查看开源项目 →</strong></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 功能特性
|
||||
|
||||
<table>
|
||||
@@ -112,9 +124,9 @@ EdgeSSH 没有独立的本地登录系统,正式入口必须先经过 Cloudfla
|
||||
2. 添加一条 **Allow(允许)** 策略,使用 **Include > Emails** 明确填写管理员邮箱;不要使用 `Everyone(所有人)` 或 `Bypass(绕过)`。
|
||||
3. 选择 **Identity Provider(标识提供程序)**。个人部署可使用 **One-time PIN(一次性 PIN)**;新建 Zero Trust 组织若没有该选项,需要先到 **Integrations(集成)> Identity providers(标识提供程序)** 手动添加。
|
||||
4. 从 Access 应用中取得 **Application Audience (AUD) Tag(应用程序受众 (AUD) 标签)**,并从 Zero Trust **Settings(设置)** 取得 **Team Domain(团队域)**。
|
||||
5. 将它们分别写入 `ACCESS_AUD` 与 `ACCESS_TEAM_DOMAIN` Worker Secret。
|
||||
5. 将它们分别保存为 GitHub Actions Secret `ACCESS_AUD` 与 `ACCESS_TEAM_DOMAIN`,由 `Deploy` workflow 同步为 Worker Secret。
|
||||
|
||||
完整的控制台点击步骤、OTP 配置、参数获取和故障排查见 **[Cloudflare Zero Trust 配置指南](docs/ZERO_TRUST.md)**。
|
||||
完整的控制台点击步骤、OTP 配置、参数获取和故障排查见 **[Cloudflare Zero Trust 配置指南](https://edgessh-docs.pages.dev/deploy/zero-trust)**。
|
||||
|
||||
### 获取项目
|
||||
|
||||
@@ -129,37 +141,62 @@ npm ci
|
||||
> [!IMPORTANT]
|
||||
> 本项目面向**单管理员**使用。Access 策略应仅允许管理员的明确身份,不要配置 Everyone 或 Bypass。缺少 Access 配置时,受保护 API 会拒绝访问,不会降级为匿名网关。
|
||||
|
||||
1. 在 Cloudflare 控制台复用或创建 D1 数据库,并调整 `wrangler.toml` 中的 Worker 名称、自定义域名与 D1 `database_id`,**不要直接使用仓库中原部署环境的资源 ID**。
|
||||
2. 在 Zero Trust 控制台创建 Self-hosted Access 应用,只允许管理员的明确邮箱或身份,并记下 Team Domain 与 Application Audience (AUD) Tag。
|
||||
3. 在 GitHub 配置一个 Actions Variable 和一个 Actions Secret,首次运行 `Deploy` workflow;它会自动检查项目、迁移 D1 并创建或更新 Worker。
|
||||
4. Worker 首次出现后,在 Cloudflare 控制台的 `Workers & Pages > 你的 Worker(默认 edgessh)> Settings > Variables and Secrets` 中添加三个运行时 Secret。保存并部署这些设置后即可通过 Access 入口验收。
|
||||
1. 在 GitHub Actions 配置 Cloudflare 账户 ID、API Token 和三项运行时 Secret;Worker 名称、D1 名称与自定义域名通过仓库 Variable 设置,不需要修改 `wrangler.toml`。
|
||||
2. 若使用自定义域名,在 Zero Trust 控制台为同一域名创建 Self-hosted Access 应用,只允许管理员的明确邮箱或身份,并记下 Team Domain 与 Application Audience (AUD) Tag。
|
||||
3. 手动运行一次 `Deploy` workflow。它会按名称查找 D1,不存在时自动创建,通过 Wrangler 同步运行时 Secret,随后执行 migration 并创建或更新 Worker;以后推送到 `main` 会复用同一资源更新部署。
|
||||
4. 通过 Access 入口登录并完成验收;无需再进入 Worker 控制台补填环境变量。
|
||||
|
||||
| 配置 | 类型与位置 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `DB` | `wrangler.toml` 中的 D1 binding | 保存加密主机资料 |
|
||||
| `DB` | 部署时生成的 D1 binding | 保存加密主机资料;数据库首次自动创建、后续按名称复用 |
|
||||
| `SSH_SESSIONS` | `wrangler.toml` 中的 Durable Object binding | 隔离 SSH 会话 |
|
||||
| `ASSETS` | `wrangler.toml` 中的静态资源 binding | 提供前端资源 |
|
||||
| `ACCESS_TEAM_DOMAIN` | Cloudflare Worker Secret | Access 团队域名,如 `my-team.cloudflareaccess.com`,不含协议和路径 |
|
||||
| `ACCESS_AUD` | Cloudflare Worker Secret | Access 应用的 Application Audience (AUD) Tag |
|
||||
| `ENCRYPTION_KEY` | Cloudflare Worker Secret | 32 字节安全随机密钥的标准 Base64 |
|
||||
| `ACCESS_TEAM_DOMAIN` | GitHub Actions Secret,同步为 Worker Secret | Access 团队域名,如 `my-team.cloudflareaccess.com`,不含协议和路径 |
|
||||
| `ACCESS_AUD` | GitHub Actions Secret,同步为 Worker Secret | Access 应用的 Application Audience (AUD) Tag |
|
||||
| `ENCRYPTION_KEY` | GitHub Actions Secret,同步为 Worker Secret | 32 字节安全随机密钥的标准 Base64 |
|
||||
| `CONNECT_TIMEOUT_MS` | 可选普通变量 | TCP 建连超时;仓库已设为 `10000`,通常无需填写 |
|
||||
|
||||
生产运行时只需在 Cloudflare 控制台填写 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD` 与 `ENCRYPTION_KEY` 三个 Worker Secret。`DB`、`SSH_SESSIONS` 与 `ASSETS` 是部署时创建的绑定,不是环境变量;`CONNECT_TIMEOUT_MS` 已有默认配置,也无需重复添加。
|
||||
生产运行时的 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD` 与 `ENCRYPTION_KEY` 统一保存在 GitHub Actions Secrets 中,workflow 会在部署时将它们同步为 Worker Secret。`DB`、`SSH_SESSIONS` 与 `ASSETS` 是部署生成的绑定,不是环境变量;其中 D1 数据库资源由 workflow 单独确保存在。`CONNECT_TIMEOUT_MS` 已有默认配置,也无需重复添加。
|
||||
|
||||
**首次部署才生成 `ENCRYPTION_KEY`,后续部署始终复用原值。** 丢失或直接替换密钥会导致已有资料无法解密。密钥只粘贴到 Cloudflare 的 Secret 输入框,不要写入源码、`wrangler.toml`、GitHub Actions 或提交记录。
|
||||
**首次部署才生成 `ENCRYPTION_KEY`,后续部署始终复用同一个 GitHub Secret。** 丢失或直接替换密钥会导致已有资料无法解密。不要把密钥保存为 GitHub Variable,也不要写入源码、`wrangler.toml`、日志或提交记录。
|
||||
|
||||
完整配置顺序、安全边界与验收清单见 [部署指南](DEPLOYMENT.md)。
|
||||
完整配置顺序、安全边界与验收清单见 [VitePress 部署指南](https://edgessh-docs.pages.dev/deploy/actions)。
|
||||
|
||||
### GitHub Actions 自动部署
|
||||
|
||||
仓库内置的 `Deploy` workflow 会在推送到 `main` 后自动检查、迁移 D1 并部署,也可在 Actions 页面手动触发。首次使用前,在仓库的 `Settings > Secrets and variables > Actions` 中配置:
|
||||
仓库内置的 `Deploy` workflow 会在推送到 `main` 后自动检查、准备 D1、执行 migration 并部署,也可在 Actions 页面手动触发。首次使用前,在仓库的 `Settings > Secrets and variables > Actions` 中配置:
|
||||
|
||||
| 名称 | GitHub 配置类型 | 用途 |
|
||||
#### 用户需要填写的内容
|
||||
|
||||
使用自定义域名完成正式部署时,用户一共需要在 GitHub Actions 中填写 **6 个值**:2 个 Variable 和 4 个 Secret。
|
||||
|
||||
| 名称 | 填写位置 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Variable | Cloudflare 账户 ID |
|
||||
| `CLOUDFLARE_API_TOKEN` | Secret | 具备 Workers 部署和 D1 迁移权限的 API Token |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | GitHub Actions Variable | Cloudflare 账户 ID |
|
||||
| `CUSTOM_DOMAIN` | GitHub Actions Variable | 自定义域名的纯主机名,例如 `ssh.example.com` |
|
||||
| `CLOUDFLARE_API_TOKEN` | GitHub Actions Secret | 具备 Workers、D1 Edit 和目标 Zone 权限的 API Token |
|
||||
| `ACCESS_TEAM_DOMAIN` | GitHub Actions Secret | Zero Trust Team Domain,不含协议和路径 |
|
||||
| `ACCESS_AUD` | GitHub Actions Secret | Access 应用的 Application Audience (AUD) Tag |
|
||||
| `ENCRYPTION_KEY` | GitHub Actions Secret | 32 字节安全随机数的标准 Base64 |
|
||||
|
||||
Action 只需要这两项部署凭据。创建 API Token 时,可使用 Cloudflare 的 `Edit Cloudflare Workers` 模板并补充 `D1: Edit`,再将 Account 与 Zone Resources 限制到实际使用的账户和域名;该模板会覆盖 Worker 与自定义域名部署所需权限。`ACCESS_TEAM_DOMAIN`、`ACCESS_AUD` 与 `ENCRYPTION_KEY` 只保存在 Cloudflare Worker Secrets 中,普通部署不会读取或覆盖它们。API Token 属于敏感信息,必须使用 GitHub Secret,不能保存为普通 Variable。
|
||||
#### 可选配置
|
||||
|
||||
| 名称 | 类型 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `WORKER_NAME` | GitHub Actions Variable | `edgessh` |
|
||||
| `D1_DATABASE_NAME` | GitHub Actions Variable | `<WORKER_NAME>-accounts` |
|
||||
|
||||
这两个名称通常保持默认值即可,不计入上述 6 个必填值。用户不需要填写 D1 ID、手动创建数据库、进入 Worker 控制台保存 Secret 或修改 `wrangler.toml`。
|
||||
|
||||
完整操作顺序:
|
||||
|
||||
1. Fork 仓库,并确保准备使用的自定义域名已经接入 Cloudflare。
|
||||
2. 创建部署用 API Token,在 GitHub 填写 `CLOUDFLARE_ACCOUNT_ID`、`CLOUDFLARE_API_TOKEN` 与 `CUSTOM_DOMAIN`。
|
||||
3. 在 Zero Trust 创建绑定同一域名的 Self-hosted Access 应用,只允许管理员身份,并取得 Team Domain 与 AUD;生成一次加密密钥,然后将 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD` 与 `ENCRYPTION_KEY` 保存为 GitHub Actions Secret。
|
||||
4. 在 GitHub 的 `Actions > Deploy` 手动运行一次 workflow;它会自动创建或复用 D1、同步 Worker Secret、执行 migration、创建或更新 Worker,并绑定自定义域名。
|
||||
5. 访问自定义域名,通过 Access 登录并完成账号 API 与 SSH 功能验收。以后推送到 `main` 会自动复用现有资源更新部署。
|
||||
|
||||
标准部署要求上表 6 项全部填写。创建 API Token 时,可使用 Cloudflare 的 `Edit Cloudflare Workers` 模板并补充 `D1: Edit`,再将 Account 与 Zone Resources 限制到实际使用的账户和域名。workflow 不会把解析出的数据库 ID 或 Secret 写回仓库:数据库 ID 只进入 runner 内的临时配置,Secret 只经标准输入交给 Wrangler。部署、D1 初始化与运行时 Secret 同步均由 Action 完成,不需要进入 Cloudflare Worker 控制台手工发布或维护另一套 Secret。
|
||||
|
||||
## 本地开发
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { defineConfig } from 'vitepress';
|
||||
|
||||
const repository = 'https://github.com/aozorae/EdgeSSH';
|
||||
|
||||
export default defineConfig({
|
||||
lang: 'zh-CN',
|
||||
title: 'EdgeSSH',
|
||||
description: 'EdgeSSH 部署、配置与使用文档',
|
||||
cleanUrls: true,
|
||||
lastUpdated: true,
|
||||
head: [
|
||||
['meta', { name: 'theme-color', content: '#f4511e' }],
|
||||
['meta', { name: 'viewport', content: 'width=device-width, initial-scale=1, viewport-fit=cover' }],
|
||||
],
|
||||
themeConfig: {
|
||||
siteTitle: 'EdgeSSH',
|
||||
logo: false,
|
||||
nav: [
|
||||
{ text: '部署', link: '/deploy/actions', activeMatch: '^/deploy/' },
|
||||
{ text: '使用', link: '/usage/hosts', activeMatch: '^/usage/' },
|
||||
{ text: '参考', link: '/reference/configuration', activeMatch: '^/reference/' },
|
||||
{ text: '开发', link: '/development/local' },
|
||||
{ text: 'GitHub', link: repository },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: '开始',
|
||||
items: [
|
||||
{ text: '认识 EdgeSSH', link: '/guide/overview' },
|
||||
{ text: '部署前准备', link: '/guide/requirements' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'GitHub Actions 部署',
|
||||
items: [
|
||||
{ text: '完整部署流程', link: '/deploy/actions' },
|
||||
{ text: 'Cloudflare API Token', link: '/deploy/api-token' },
|
||||
{ text: 'Zero Trust Access', link: '/deploy/zero-trust' },
|
||||
{ text: 'Worker 运行时 Secret', link: '/deploy/runtime-secrets' },
|
||||
{ text: '部署后验收', link: '/deploy/verification' },
|
||||
{ text: '常见部署问题', link: '/deploy/troubleshooting' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '日常使用',
|
||||
items: [
|
||||
{ text: '管理主机', link: '/usage/hosts' },
|
||||
{ text: '终端与主机指纹', link: '/usage/terminal' },
|
||||
{ text: '文件与进程', link: '/usage/files-and-processes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '参考',
|
||||
items: [
|
||||
{ text: '配置项', link: '/reference/configuration' },
|
||||
{ text: '架构与安全边界', link: '/reference/security' },
|
||||
{ text: '支持范围与限制', link: '/reference/limits' },
|
||||
{ text: '截图清单', link: '/reference/screenshots' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '开发',
|
||||
items: [
|
||||
{ text: '本地开发', link: '/development/local' },
|
||||
{ text: '贡献指南', link: '/development/contributing' },
|
||||
{ text: '文档站部署', link: '/development/docs-site' },
|
||||
],
|
||||
},
|
||||
],
|
||||
socialLinks: [{ icon: 'github', link: repository }],
|
||||
search: {
|
||||
provider: 'local',
|
||||
options: {
|
||||
translations: {
|
||||
button: { buttonText: '搜索文档', buttonAriaLabel: '搜索文档' },
|
||||
modal: {
|
||||
noResultsText: '没有找到相关内容',
|
||||
resetButtonTitle: '清除查询',
|
||||
footer: { selectText: '选择', navigateText: '切换', closeText: '关闭' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
outline: { level: [2, 3], label: '本页内容' },
|
||||
editLink: { pattern: `${repository}/edit/main/docs/:path`, text: '在 GitHub 上编辑此页' },
|
||||
lastUpdated: { text: '最后更新', formatOptions: { dateStyle: 'medium', timeStyle: 'short' } },
|
||||
docFooter: { prev: '上一页', next: '下一页' },
|
||||
returnToTopLabel: '返回顶部',
|
||||
sidebarMenuLabel: '文档目录',
|
||||
darkModeSwitchLabel: '切换主题',
|
||||
lightModeSwitchTitle: '切换到浅色模式',
|
||||
darkModeSwitchTitle: '切换到深色模式',
|
||||
footer: {
|
||||
message: '面向个人管理员的 Cloudflare 边缘 SSH 工作台',
|
||||
copyright: 'Apache License 2.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
filename: string;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure class="screenshot-slot">
|
||||
<img v-if="src" :src="src" :alt="alt || title" loading="lazy" />
|
||||
<div v-else class="screenshot-slot__empty">
|
||||
<span class="screenshot-slot__mark" aria-hidden="true">⌗</span>
|
||||
<div>
|
||||
<strong>{{ title }}</strong>
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption v-if="src">
|
||||
{{ caption || '界面参考' }}
|
||||
</figcaption>
|
||||
<figcaption v-else>
|
||||
待补截图:<code>docs/public/screenshots/{{ filename }}</code>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
import DefaultTheme from 'vitepress/theme';
|
||||
import type { Theme } from 'vitepress';
|
||||
import ScreenshotPlaceholder from './components/ScreenshotPlaceholder.vue';
|
||||
import './style.css';
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
enhanceApp({ app }) {
|
||||
app.component('ScreenshotPlaceholder', ScreenshotPlaceholder);
|
||||
},
|
||||
} satisfies Theme;
|
||||
@@ -0,0 +1,499 @@
|
||||
/* Hallmark · pre-emit critique: P5 H4 E4 S5 R5 V4 · genre: modern-minimal · macrostructure: Workbench · theme: Coral · enrichment: real product capture · nav: N13 search-first · footer: Ft1 mast-headed · contrast: pass · mobile: pass */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@600;700&family=JetBrains+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;600;700&display=swap');
|
||||
@import './tokens.css';
|
||||
|
||||
html,
|
||||
body {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
:root {
|
||||
--vp-c-bg: var(--color-paper);
|
||||
--vp-c-bg-alt: var(--color-paper-soft);
|
||||
--vp-c-bg-elv: var(--color-paper-raised);
|
||||
--vp-c-bg-soft: var(--color-paper-soft);
|
||||
--vp-c-text-1: var(--color-ink);
|
||||
--vp-c-text-2: var(--color-ink-soft);
|
||||
--vp-c-text-3: var(--color-muted);
|
||||
--vp-c-divider: var(--color-rule);
|
||||
--vp-c-gutter: var(--color-rule);
|
||||
--vp-c-brand-1: var(--color-accent-strong);
|
||||
--vp-c-brand-2: var(--color-accent);
|
||||
--vp-c-brand-3: var(--color-accent-strong);
|
||||
--vp-c-brand-soft: var(--color-accent-soft);
|
||||
--vp-font-family-base: var(--font-body);
|
||||
--vp-font-family-mono: var(--font-mono);
|
||||
--vp-layout-max-width: 1440px;
|
||||
--vp-sidebar-width: 288px;
|
||||
--vp-border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-paper);
|
||||
color: var(--color-ink-soft);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
.VPNavBarTitle,
|
||||
.VPHomeHero .name,
|
||||
.VPHomeHero .text {
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-style: normal;
|
||||
letter-spacing: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
.VPButton {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
.VPButton:focus-visible {
|
||||
outline: 3px solid var(--color-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.VPNav {
|
||||
border-bottom: var(--rule-thin) solid var(--color-rule);
|
||||
}
|
||||
|
||||
.VPNavBarTitle .title::before {
|
||||
color: var(--color-accent);
|
||||
content: '>_';
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
margin-inline-end: var(--space-sm);
|
||||
}
|
||||
|
||||
.VPNavBarTitle .title,
|
||||
.VPNavBarMenuLink,
|
||||
.VPNavBarMenuGroup .text {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.DocSearch-Button,
|
||||
.VPNavBarSearch .DocSearch-Button {
|
||||
border: var(--rule-thin) solid var(--color-rule-strong);
|
||||
border-radius: var(--radius-md);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.VPHome {
|
||||
padding-bottom: var(--space-3xl);
|
||||
}
|
||||
|
||||
.VPHomeHero {
|
||||
padding: var(--space-2xl) var(--space-lg) var(--space-3xl);
|
||||
}
|
||||
|
||||
.VPHomeHero .container {
|
||||
display: block;
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
.VPHomeHero .main {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.VPHomeHero .name {
|
||||
color: var(--color-ink);
|
||||
font-size: var(--text-display);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.VPHomeHero .name .clip {
|
||||
background: none;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
}
|
||||
|
||||
.VPHomeHero .text {
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1.2;
|
||||
max-width: 44rem;
|
||||
}
|
||||
|
||||
.VPHomeHero .tagline {
|
||||
color: var(--color-ink-soft);
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.7;
|
||||
max-width: 44rem;
|
||||
}
|
||||
|
||||
.VPButton {
|
||||
border-radius: var(--radius-md) !important;
|
||||
font-weight: 600;
|
||||
min-height: 44px;
|
||||
transition: background-color var(--dur-short) var(--ease-out), color var(--dur-short) var(--ease-out), transform var(--dur-micro) var(--ease-out);
|
||||
}
|
||||
|
||||
.VPButton:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.VPButton.brand {
|
||||
background: var(--color-accent-strong);
|
||||
border-color: var(--color-accent-strong);
|
||||
color: var(--color-paper-raised);
|
||||
}
|
||||
|
||||
.VPButton.alt {
|
||||
background: transparent;
|
||||
border-color: var(--color-rule-strong);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.home-workbench,
|
||||
.home-path,
|
||||
.home-boundary {
|
||||
margin-inline: auto;
|
||||
max-width: 1120px;
|
||||
padding-inline: var(--space-lg);
|
||||
}
|
||||
|
||||
.home-workbench {
|
||||
display: grid;
|
||||
gap: var(--space-xl);
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(240px, 0.45fr);
|
||||
}
|
||||
|
||||
.home-capture {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.home-capture img {
|
||||
border: var(--rule-thin) solid var(--color-rule-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
display: block;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.home-capture figcaption,
|
||||
.screenshot-slot figcaption {
|
||||
color: var(--color-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.home-workbench__note {
|
||||
align-self: end;
|
||||
border-top: var(--rule-thin) solid var(--color-rule-strong);
|
||||
padding-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.home-workbench__note strong {
|
||||
color: var(--color-ink);
|
||||
display: block;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.home-workbench__note p {
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.home-path {
|
||||
padding-top: var(--space-3xl);
|
||||
}
|
||||
|
||||
.home-path h2,
|
||||
.home-boundary h2 {
|
||||
border: 0;
|
||||
font-size: var(--text-xl);
|
||||
margin: 0 0 var(--space-xl);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.deploy-track {
|
||||
border-top: var(--rule-thin) solid var(--color-rule-strong);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.deploy-track > div {
|
||||
border-bottom: var(--rule-thin) solid var(--color-rule);
|
||||
padding: var(--space-lg) var(--space-md) var(--space-xl) 0;
|
||||
}
|
||||
|
||||
.deploy-track > div + div {
|
||||
border-inline-start: var(--rule-thin) solid var(--color-rule);
|
||||
padding-inline-start: var(--space-md);
|
||||
}
|
||||
|
||||
.deploy-track span {
|
||||
color: var(--color-accent-strong);
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.deploy-track strong {
|
||||
color: var(--color-ink);
|
||||
display: block;
|
||||
font-size: var(--text-md);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.deploy-track p {
|
||||
color: var(--color-ink-soft);
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.home-boundary {
|
||||
display: grid;
|
||||
gap: var(--space-xl);
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
|
||||
padding-top: var(--space-3xl);
|
||||
}
|
||||
|
||||
.home-boundary > div:last-child {
|
||||
border-top: var(--rule-thin) solid var(--color-rule-strong);
|
||||
line-height: 1.8;
|
||||
padding-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.home-link {
|
||||
color: var(--color-accent-strong);
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: var(--rule-thin);
|
||||
text-underline-offset: var(--space-2xs);
|
||||
}
|
||||
|
||||
.vp-doc h1 {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.16;
|
||||
}
|
||||
|
||||
.vp-doc h2 {
|
||||
border-top: var(--rule-thin) solid var(--color-rule);
|
||||
font-size: var(--text-xl);
|
||||
margin-top: var(--space-2xl);
|
||||
padding-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.vp-doc h3 {
|
||||
font-size: var(--text-lg);
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.vp-doc p,
|
||||
.vp-doc li {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.vp-doc a {
|
||||
text-underline-offset: var(--space-2xs);
|
||||
}
|
||||
|
||||
.vp-doc .header-anchor {
|
||||
float: none;
|
||||
margin-inline-start: var(--space-xs);
|
||||
opacity: 0;
|
||||
position: static;
|
||||
}
|
||||
|
||||
.vp-doc :is(h1, h2, h3, h4):hover .header-anchor,
|
||||
.vp-doc .header-anchor:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vp-doc table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vp-doc tr {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.vp-doc th,
|
||||
.vp-doc td {
|
||||
border-color: var(--color-rule);
|
||||
}
|
||||
|
||||
.vp-doc code {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.vp-doc div[class*='language-'] {
|
||||
background: var(--color-code);
|
||||
border: var(--rule-thin) solid var(--color-rule-strong);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.vp-doc div[class*='language-'] code {
|
||||
color: var(--color-code-ink);
|
||||
}
|
||||
|
||||
.custom-block {
|
||||
border: var(--rule-thin) solid var(--color-rule-strong);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.custom-block.tip {
|
||||
background: var(--color-success-soft);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.custom-block.info {
|
||||
background: var(--color-info-soft);
|
||||
border-color: var(--color-info);
|
||||
}
|
||||
|
||||
.custom-block.warning,
|
||||
.custom-block.danger {
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.screenshot-slot {
|
||||
margin: var(--space-xl) 0;
|
||||
}
|
||||
|
||||
.screenshot-slot img,
|
||||
.screenshot-slot__empty {
|
||||
border: var(--rule-thin) dashed var(--color-rule-strong);
|
||||
border-radius: var(--radius-md);
|
||||
min-height: 260px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.screenshot-slot img {
|
||||
display: block;
|
||||
height: auto;
|
||||
margin-inline: auto;
|
||||
min-height: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.screenshot-slot__empty {
|
||||
align-items: center;
|
||||
background: var(--color-paper-soft);
|
||||
display: flex;
|
||||
gap: var(--space-lg);
|
||||
justify-content: center;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.screenshot-slot__mark {
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.screenshot-slot__empty strong {
|
||||
color: var(--color-ink);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.screenshot-slot__empty p {
|
||||
color: var(--color-ink-soft);
|
||||
margin: var(--space-xs) 0 0;
|
||||
max-width: 54ch;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.VPButton:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.home-link:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 59.99rem) {
|
||||
.home-workbench,
|
||||
.home-boundary {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.deploy-track {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.deploy-track > div:nth-child(3) {
|
||||
border-inline-start: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 39.99rem) {
|
||||
.VPHomeHero {
|
||||
padding: var(--space-xl) var(--space-md) var(--space-2xl);
|
||||
}
|
||||
|
||||
.VPHomeHero .name {
|
||||
font-size: 2.75rem;
|
||||
}
|
||||
|
||||
.VPHomeHero .text,
|
||||
.home-path h2,
|
||||
.home-boundary h2 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.DocSearch-Button,
|
||||
.VPNavBarSearch .DocSearch-Button {
|
||||
border: 0;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0;
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
.home-workbench,
|
||||
.home-path,
|
||||
.home-boundary {
|
||||
padding-inline: var(--space-md);
|
||||
}
|
||||
|
||||
.deploy-track {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.deploy-track > div,
|
||||
.deploy-track > div + div,
|
||||
.deploy-track > div:nth-child(3) {
|
||||
border-inline-start: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.screenshot-slot__empty {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
min-height: 220px;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.vp-doc h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
:root {
|
||||
--color-paper: oklch(98.5% 0.006 240);
|
||||
--color-paper-raised: oklch(100% 0.004 240);
|
||||
--color-paper-soft: oklch(96% 0.01 230);
|
||||
--color-ink: oklch(22% 0.025 250);
|
||||
--color-ink-soft: oklch(42% 0.025 245);
|
||||
--color-muted: oklch(55% 0.02 240);
|
||||
--color-rule: oklch(89% 0.014 235);
|
||||
--color-rule-strong: oklch(78% 0.025 235);
|
||||
--color-accent: oklch(64% 0.2 40);
|
||||
--color-accent-strong: oklch(55% 0.2 38);
|
||||
--color-accent-soft: oklch(95% 0.035 45);
|
||||
--color-success: oklch(52% 0.13 157);
|
||||
--color-success-soft: oklch(95% 0.025 157);
|
||||
--color-info: oklch(56% 0.14 245);
|
||||
--color-info-soft: oklch(95% 0.025 245);
|
||||
--color-warning: oklch(64% 0.15 78);
|
||||
--color-code: oklch(19% 0.024 250);
|
||||
--color-code-ink: oklch(92% 0.012 235);
|
||||
--color-focus: oklch(58% 0.18 40);
|
||||
|
||||
--font-display: "Bricolage Grotesque", "Noto Sans SC", sans-serif;
|
||||
--font-body: "Noto Sans SC", "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Cascadia Code", monospace;
|
||||
|
||||
--space-3xs: 0.125rem;
|
||||
--space-2xs: 0.25rem;
|
||||
--space-xs: 0.5rem;
|
||||
--space-sm: 0.75rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2.5rem;
|
||||
--space-2xl: 4rem;
|
||||
--space-3xl: 6rem;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-md: 1.125rem;
|
||||
--text-lg: 1.5rem;
|
||||
--text-xl: 2rem;
|
||||
--text-display: 3.5rem;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--rule-thin: 1px;
|
||||
--dur-micro: 120ms;
|
||||
--dur-short: 220ms;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-paper: oklch(17% 0.018 250);
|
||||
--color-paper-raised: oklch(21% 0.02 250);
|
||||
--color-paper-soft: oklch(24% 0.02 245);
|
||||
--color-ink: oklch(94% 0.01 235);
|
||||
--color-ink-soft: oklch(76% 0.018 235);
|
||||
--color-muted: oklch(66% 0.018 235);
|
||||
--color-rule: oklch(31% 0.025 245);
|
||||
--color-rule-strong: oklch(43% 0.035 240);
|
||||
--color-accent: oklch(72% 0.17 48);
|
||||
--color-accent-strong: oklch(78% 0.14 50);
|
||||
--color-accent-soft: oklch(27% 0.045 42);
|
||||
--color-success: oklch(72% 0.12 157);
|
||||
--color-success-soft: oklch(25% 0.04 157);
|
||||
--color-info: oklch(72% 0.11 245);
|
||||
--color-info-soft: oklch(25% 0.04 245);
|
||||
--color-warning: oklch(78% 0.12 82);
|
||||
--color-code: oklch(13% 0.018 250);
|
||||
--color-code-ink: oklch(92% 0.012 235);
|
||||
--color-focus: oklch(74% 0.15 48);
|
||||
}
|
||||
+9
-13
@@ -113,20 +113,16 @@ my-team.cloudflareaccess.com
|
||||
Cloudflare 官方获取 AUD 的说明:
|
||||
https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/
|
||||
|
||||
## 在 Cloudflare 控制台保存 Worker Secret
|
||||
## 在 GitHub Actions 保存 Worker Secret
|
||||
|
||||
先按 [部署指南](../DEPLOYMENT.md) 配置 GitHub Actions,并手动运行一次 `Deploy` workflow。首次部署完成、Worker 出现在 Cloudflare 后:
|
||||
取得 Team Domain 与 AUD 后,在 GitHub 仓库进入 `Settings > Secrets and variables > Actions`:
|
||||
|
||||
1. 打开 Cloudflare Dashboard,进入 `Workers & Pages`。
|
||||
2. 选择你的 EdgeSSH Worker(默认名称为 `edgessh`)。
|
||||
3. 进入 `Settings(设置)> Variables and Secrets(变量与机密)`。
|
||||
4. 添加 `ACCESS_TEAM_DOMAIN`,类型选择 **Secret**,粘贴前面取得的 Team Domain。
|
||||
5. 添加 `ACCESS_AUD`,类型选择 **Secret**,粘贴前面取得的 Application Audience (AUD) Tag。
|
||||
6. 按控制台提示保存并部署新版本。
|
||||
1. 添加 `ACCESS_TEAM_DOMAIN` Secret,粘贴前面取得的 Team Domain。
|
||||
2. 添加 `ACCESS_AUD` Secret,粘贴前面取得的 Application Audience (AUD) Tag。
|
||||
3. 添加 `ENCRYPTION_KEY` Secret。按照[运行时 Secret 指南](https://edgessh-docs.pages.dev/deploy/runtime-secrets#生成-encryption_key)生成 32 字节安全随机数的标准 Base64;首次生成后必须妥善保管,后续部署继续使用同一个值。
|
||||
4. 确认三项 Secret 均已添加,再手动运行一次 `Deploy` workflow。
|
||||
|
||||
不要把这两个值保存为 GitHub Actions Secret。GitHub Actions 只需要 `CLOUDFLARE_ACCOUNT_ID` 和 `CLOUDFLARE_API_TOKEN` 两项部署凭据;Access 参数属于 Worker 运行时 Secret,应始终留在 Cloudflare 中。
|
||||
|
||||
EdgeSSH 还需要一个 `ENCRYPTION_KEY` Worker Secret。它与上述两项在同一个控制台页面配置,生成要求和密钥保管注意事项见 [部署指南](../DEPLOYMENT.md#5-在-worker-界面添加运行时-secret)。生产部署不需要在本地执行 Wrangler Secret 或 Worker 部署命令。
|
||||
workflow 会通过 Wrangler 将这三项值同步为同名 Worker Secret。不要将它们保存成 GitHub Actions Variable,也不要写进仓库、本地命令或日志;生产部署不需要手动执行 Wrangler Secret 或 Worker 部署命令。
|
||||
|
||||
## 验证配置
|
||||
|
||||
@@ -144,9 +140,9 @@ EdgeSSH 还需要一个 `ENCRYPTION_KEY` Worker Secret。它与上述两项在
|
||||
|
||||
检查:
|
||||
|
||||
- `ACCESS_TEAM_DOMAIN` 是否已经在 Worker 的 `Variables and Secrets` 页面保存为 Secret,并部署到当前版本。
|
||||
- `ACCESS_TEAM_DOMAIN` 是否已经保存为 GitHub Actions Secret,并由最新一次 `Deploy` workflow 成功同步。
|
||||
- Team Domain 是否为 `xxx.cloudflareaccess.com`,且没有 `https://`。
|
||||
- `ACCESS_AUD` 是否已在同一页面保存为 Secret,且值来自当前 Access 应用。
|
||||
- `ACCESS_AUD` 是否已保存为 GitHub Actions Secret,且值来自当前 Access 应用。
|
||||
|
||||
### 登录后提示“Access 登录已失效”
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# 使用 GitHub Actions 部署
|
||||
|
||||
这是推荐的生产部署方式。首次执行会创建或复用 D1、同步运行时 Secret、应用数据库迁移并发布 Worker;以后推送到 `main` 会复用同一批资源更新代码。
|
||||
|
||||
## 开始前必须填写的 6 项
|
||||
|
||||
正式部署只使用 GitHub 仓库的 `Settings → Secrets and variables → Actions` 作为配置入口。先一次填完下面 6 项,再运行 `Deploy` workflow:
|
||||
|
||||
| 名称 | GitHub Actions 类型 | 填写内容 |
|
||||
| --- | --- | --- |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Variable | Cloudflare 账户 ID |
|
||||
| `CUSTOM_DOMAIN` | Variable | EdgeSSH 的自定义域名,只写主机名 |
|
||||
| `CLOUDFLARE_API_TOKEN` | Secret | 具备 Worker、D1 与目标 Zone 权限的 API Token |
|
||||
| `ACCESS_TEAM_DOMAIN` | Secret | Zero Trust Team Domain,不带协议或路径 |
|
||||
| `ACCESS_AUD` | Secret | EdgeSSH Access 应用的 AUD Tag |
|
||||
| `ENCRYPTION_KEY` | Secret | 固定的 32 字节安全随机数 Base64 |
|
||||
|
||||
::: danger 六项缺一不可
|
||||
本教程的正式部署流程要求 2 个 Variable 与 4 个 Secret 全部存在。运行时 Secret 会由 workflow 自动同步到 Worker;不要再到 Cloudflare Worker 控制台保存一套配置,也不要在控制台手工部署。
|
||||
:::
|
||||
|
||||
## 部署路线图
|
||||
|
||||
1. Fork EdgeSSH 仓库。
|
||||
2. 创建 Cloudflare API Token。
|
||||
3. 在 Cloudflare Zero Trust 创建 Access 应用。
|
||||
4. 在 GitHub 保存全部六项正式部署配置。
|
||||
5. 手动运行一次 `Deploy` workflow。
|
||||
6. 确认 workflow 已同步三个运行时 Secret。
|
||||
7. 访问自定义域名并完成验收。
|
||||
|
||||
::: tip 一个配置入口
|
||||
六项正式部署配置全部保存在 GitHub Actions:账户 ID 与自定义域名使用 Variable,API Token、Access 参数与加密密钥使用 Secret。workflow 会通过 Cloudflare API 将三项运行时值同步为 Worker Secret,无需再到 Worker 控制台补填。
|
||||
:::
|
||||
|
||||
## 1. Fork 仓库
|
||||
|
||||
打开 [EdgeSSH GitHub 仓库](https://github.com/aozorae/EdgeSSH),选择 **Fork**,保留默认分支 `main`。
|
||||
|
||||
Fork 完成后,地址通常是:
|
||||
|
||||
```text
|
||||
https://github.com/<你的 GitHub 用户名>/EdgeSSH
|
||||
```
|
||||
|
||||
<ScreenshotPlaceholder
|
||||
title="Fork 仓库"
|
||||
description="请截取仓库右上角 Fork 入口和创建 Fork 页面。不要让浏览器个人资料菜单出现在画面中。"
|
||||
filename="01-fork-repository.png"
|
||||
src="/screenshots/01-fork-repository.png"
|
||||
alt="GitHub 仓库页面右上角的 Fork 按钮"
|
||||
caption="GitHub Fork 入口示例;实际操作时请选择 EdgeSSH 仓库。"
|
||||
/>
|
||||
|
||||
## 2. 创建 Cloudflare API Token
|
||||
|
||||
在 Cloudflare 个人资料的 **API Tokens** 页面创建 Token。以官方 **Edit Cloudflare Workers** 模板为起点,并确认 Token 具备:
|
||||
|
||||
- Account:Workers Scripts Edit。
|
||||
- Account:D1 Edit。
|
||||
- Zone:目标域名所需的 Workers Routes Edit 权限。
|
||||
- Account Resources:只选择实际部署所用账户。
|
||||
- Zone Resources:只选择 EdgeSSH 域名所在 Zone。
|
||||
|
||||
Token 只会完整显示一次。创建后直接保存到 GitHub Secret,不要粘贴到聊天、Issue、截图或仓库文件。
|
||||
|
||||
详细配置见[创建 API Token](/deploy/api-token)。
|
||||
|
||||
## 3. 在 GitHub Actions 填写六项配置
|
||||
|
||||
进入你的 Fork:
|
||||
|
||||
```text
|
||||
Settings → Secrets and variables → Actions
|
||||
```
|
||||
|
||||
按照页面顶部的必填表,在 **Variables** 页签保存 `CLOUDFLARE_ACCOUNT_ID` 与 `CUSTOM_DOMAIN`,在 **Secrets** 页签保存另外四项。三项运行时 Secret 的值在下一节取得。Variable 的值会显示在设置页,Secret 保存后不会再次显示;不要把四项敏感值错放到 Variable。
|
||||
|
||||
### 可选配置
|
||||
|
||||
以下 Variable 不影响标准部署,第一次部署通常不要填写:
|
||||
|
||||
| 名称 | 默认值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `WORKER_NAME` | `edgessh` | 自定义 Worker 名称 |
|
||||
| `D1_DATABASE_NAME` | `<WORKER_NAME>-accounts` | 自定义 D1 数据库名称 |
|
||||
|
||||
`DOCS_PROJECT_NAME` 只属于文档站的 `Deploy docs` workflow,不是 EdgeSSH 应用部署的必填项。
|
||||
|
||||
<ScreenshotPlaceholder
|
||||
title="GitHub Actions 变量与 Secret"
|
||||
description="请截取 Repository variables 列表和 Repository secrets 名称列表,不要展示任何 Secret 值。画面需能看清六项正式部署配置的名称。"
|
||||
filename="03-github-actions-variables.png"
|
||||
src="/screenshots/03-github-actions-variables.png"
|
||||
alt="GitHub Actions Secrets and variables 设置页面"
|
||||
caption="GitHub Actions 配置入口示例;具体名称与类型以上方六项必填表为准。"
|
||||
/>
|
||||
|
||||
## 4. 配置 Zero Trust Access
|
||||
|
||||
在运行 Action 前,先为 `CUSTOM_DOMAIN` 中的同一域名创建 Self-hosted Access 应用,并添加只允许明确管理员邮箱的 Allow 策略。
|
||||
|
||||
你需要从这个应用取得两个值:
|
||||
|
||||
- `ACCESS_TEAM_DOMAIN`:例如 `my-team.cloudflareaccess.com`。
|
||||
- `ACCESS_AUD`:Application Audience (AUD) Tag。
|
||||
|
||||
再按[运行时 Secret](/deploy/runtime-secrets)中的方式生成一次 `ENCRYPTION_KEY`,回到 GitHub Actions Secrets 保存这三项值。加密密钥在后续部署中必须保持不变。
|
||||
|
||||
完整点击路径见[配置 Zero Trust Access](/deploy/zero-trust)。
|
||||
|
||||
## 5. 首次运行 Deploy
|
||||
|
||||
进入 Fork 的:
|
||||
|
||||
```text
|
||||
Actions → Deploy → Run workflow
|
||||
```
|
||||
|
||||
选择 `main` 后运行。workflow 会依次:
|
||||
|
||||
1. 检出仓库并安装锁定版本依赖。
|
||||
2. 执行类型检查、自动化测试、文档构建与 Wrangler dry-run。
|
||||
3. 按 `D1_DATABASE_NAME` 精确查找 D1,不存在时创建一次。
|
||||
4. 生成只存在于 runner 内的临时 Wrangler 配置,写入真实 D1 ID。
|
||||
5. 从 GitHub Actions 读取三项运行时 Secret,并经标准输入同步给 Wrangler。
|
||||
6. 对远程 D1 执行 migration。
|
||||
7. 创建或更新 Worker,并绑定必填配置中的自定义域名。
|
||||
|
||||
<ScreenshotPlaceholder
|
||||
title="手动运行 Deploy workflow"
|
||||
description="请截取 Actions 页面的 Deploy 工作流和 Run workflow 菜单,画面需能看清 main 分支选择。"
|
||||
filename="04-run-deploy-workflow.png"
|
||||
src="/screenshots/04-run-deploy-workflow.png"
|
||||
alt="GitHub Actions 的 Run workflow 菜单和 main 分支选择"
|
||||
caption="Run workflow 菜单示例;请在 EdgeSSH 的 Deploy workflow 中选择 main。"
|
||||
/>
|
||||
|
||||
### 怎样判断成功
|
||||
|
||||
等待运行记录显示绿色成功标记,并展开 **Prepare Cloudflare resources**、**Sync Worker secrets** 与对应的部署步骤。资源准备日志中应能看到:
|
||||
|
||||
```text
|
||||
已创建 D1 数据库:edgessh-accounts
|
||||
```
|
||||
|
||||
或后续部署中的:
|
||||
|
||||
```text
|
||||
已复用 D1 数据库:edgessh-accounts
|
||||
```
|
||||
|
||||
日志还应显示 Worker 名称和自定义域名,但不应出现 API Token、Access 参数或加密密钥。
|
||||
|
||||
<ScreenshotPlaceholder
|
||||
title="Deploy 成功记录"
|
||||
description="请截取成功的 workflow 摘要和关键部署步骤。隐藏仓库外的私人信息,确认日志中没有任何 Token、Secret 或 Cookie。"
|
||||
filename="05-deploy-success.png"
|
||||
/>
|
||||
|
||||
::: info 缺少 Secret 会直接停止部署
|
||||
workflow 会在发布 Worker 前校验三项运行时 Secret。任一项缺失时部署会失败,不会留下一个需要去控制台补配置的版本。
|
||||
:::
|
||||
|
||||
## 6. 确认运行时 Secret 已同步
|
||||
|
||||
成功的运行记录中,**Sync Worker secrets** 步骤会列出已处理的 Secret 名称,但不会显示值。三项值仅经标准输入交给 Wrangler,不进入命令参数、临时配置或日志。
|
||||
|
||||
如需核验,可在 Cloudflare Worker 的 Variables and Secrets 页面确认三个名称存在;该页面不是配置入口,也不需要再次保存或部署。生成与保管加密密钥的规则见[Worker 运行时 Secret](/deploy/runtime-secrets)。
|
||||
|
||||
## 7. 完成首次验收
|
||||
|
||||
访问 `https://你的自定义域名`。正确顺序是:
|
||||
|
||||
1. 浏览器先进入 Cloudflare Access 登录页。
|
||||
2. 只有 Allow 策略中的邮箱可以完成登录。
|
||||
3. 登录后显示 EdgeSSH 主机总览,而不是配置错误。
|
||||
4. 新建一台测试主机,核对首次连接指纹,再验收终端、SFTP 与进程面板。
|
||||
|
||||
不要只看到首页就宣布部署完成。使用[部署后验收清单](/deploy/verification)逐项检查。
|
||||
|
||||
## 后续更新
|
||||
|
||||
以后将更新合并或推送到 `main` 即可。Action 会复用同名 D1,并从 GitHub Actions Secrets 幂等同步 Worker Secret;不需要在本地运行 Wrangler,也不需要重复创建资源。
|
||||
|
||||
保持 `WORKER_NAME` 和 `D1_DATABASE_NAME` 稳定。修改数据库名称会切换到另一个数据库,不会自动迁移旧数据。
|
||||
@@ -0,0 +1,62 @@
|
||||
# 创建 Cloudflare API Token
|
||||
|
||||
部署 Token 只负责让 GitHub Actions 管理 Worker、D1 与自定义域名。它不是 EdgeSSH 的登录凭据,也不应作为 Worker 运行时变量。
|
||||
|
||||
## 创建入口
|
||||
|
||||
1. 登录 Cloudflare Dashboard。
|
||||
2. 打开右上角个人资料。
|
||||
3. 进入 **My Profile → API Tokens**。
|
||||
4. 选择 **Create Token**。
|
||||
5. 以 **Edit Cloudflare Workers** 模板作为起点。
|
||||
|
||||
## 权限建议
|
||||
|
||||
确保 Token 覆盖以下能力:
|
||||
|
||||
| 范围 | 权限 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| Account | Workers Scripts: Edit | 创建或更新 EdgeSSH Worker |
|
||||
| Account | D1: Edit | 查找、创建 D1 并执行 migration |
|
||||
| Zone | Workers Routes: Edit | 绑定 `CUSTOM_DOMAIN` |
|
||||
|
||||
Cloudflare 控制台的权限名称可能随界面调整。判断标准是:Token 能部署 Worker、管理目标账户的 D1,并为目标 Zone 配置 Worker 自定义域名。
|
||||
|
||||
资源范围应限定到实际账户和域名。EdgeSSH 不使用 KV 或 R2,不要为了省事授予无关资源权限。
|
||||
|
||||
<ScreenshotPlaceholder
|
||||
title="API Token 权限与资源范围"
|
||||
description="请截取权限表和 Account/Zone Resources 选择结果。必须遮盖 Token 值、账户邮箱和与教程无关的域名。"
|
||||
filename="02-cloudflare-api-token-permissions.png"
|
||||
src="/screenshots/02-cloudflare-api-token-permissions.png"
|
||||
alt="Cloudflare API Token 模板选择页面"
|
||||
caption="Cloudflare Token 模板入口示例;最终权限与资源范围以本页表格为准。"
|
||||
/>
|
||||
|
||||
## 保存到 GitHub
|
||||
|
||||
Token 创建完成后,只会完整显示一次:
|
||||
|
||||
1. 复制 Token。
|
||||
2. 打开 Fork 的 `Settings → Secrets and variables → Actions`。
|
||||
3. 在 **Secrets** 中创建 `CLOUDFLARE_API_TOKEN`。
|
||||
4. 粘贴并保存。
|
||||
|
||||
不要把 Token 存为普通 Variable。也不要把 Token 写入 `.env`、`.dev.vars`、`wrangler.toml`、README 或截图。
|
||||
|
||||
API Token 只是正式部署的 6 项必填配置之一。创建完成后回到[GitHub Actions 完整部署流程](/deploy/actions#开始前必须填写的-6-项),将 2 个 Variable 与 4 个 Secret 一次配置完整。
|
||||
|
||||
## 验证与排错
|
||||
|
||||
Action 报 `Authentication error` 或 `Invalid API Token` 时:
|
||||
|
||||
- 确认复制的是 API Token,不是 Global API Key。
|
||||
- 确认 Secret 名称精确为 `CLOUDFLARE_API_TOKEN`。
|
||||
- 确认 Token 尚未被撤销或过期。
|
||||
- 确认 Account Resources 包含 `CLOUDFLARE_ACCOUNT_ID` 对应账户。
|
||||
|
||||
Action 能部署 Worker、但无法创建 D1 时,通常是缺少 Account `D1: Edit`。能部署但自定义域名绑定失败时,通常是 Zone 范围或 Workers Routes 权限不正确。
|
||||
|
||||
## 文档站 Pages 权限
|
||||
|
||||
仓库的 `Deploy docs` workflow 会把 VitePress 静态产物发布到独立 Cloudflare Pages 项目。若同一个 Token 也用于文档站,请额外确认它具备 Account `Cloudflare Pages: Edit`。文档项目默认名为 `edgessh-docs`,不会访问 EdgeSSH 的 D1 或 Worker Secret。
|
||||
@@ -0,0 +1,71 @@
|
||||
# 通过 GitHub Actions 配置 Worker 运行时 Secret
|
||||
|
||||
三个运行时 Secret 统一保存在 GitHub Actions Secrets 中。`Deploy` workflow 会在发布前将它们同步到 Worker,不需要进入 Cloudflare Worker 控制台补填。
|
||||
|
||||
## 打开配置页
|
||||
|
||||
在 GitHub 仓库进入:
|
||||
|
||||
```text
|
||||
Settings → Secrets and variables → Actions → Secrets
|
||||
```
|
||||
|
||||
## 添加三项 Secret
|
||||
|
||||
| 名称 | 值 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| `ACCESS_TEAM_DOMAIN` | `my-team.cloudflareaccess.com` | Zero Trust Settings |
|
||||
| `ACCESS_AUD` | 一串 Audience Tag | EdgeSSH Access 应用 |
|
||||
| `ENCRYPTION_KEY` | 32 字节随机数的标准 Base64 | 本机安全随机数工具 |
|
||||
|
||||
三项都创建为 Repository Secret,不要保存为 Repository Variable。配置完成后运行 `Deploy` workflow,**Sync Worker secrets** 步骤会通过 Wrangler 批量同步它们。
|
||||
|
||||
<ScreenshotPlaceholder
|
||||
title="GitHub Actions Secrets"
|
||||
description="请截取三项运行时 Secret 的名称列表。不要展示任何值,也不要展开编辑面板。"
|
||||
filename="08-github-actions-secrets.png"
|
||||
src="/screenshots/08-github-actions-secrets.png"
|
||||
alt="GitHub Actions Repository secrets 设置入口"
|
||||
caption="Repository secrets 配置入口示例;请按上表分别创建三项运行时 Secret。"
|
||||
/>
|
||||
|
||||
## 生成 ENCRYPTION_KEY
|
||||
|
||||
在可信的本机终端运行下列任一命令。
|
||||
|
||||
### Node.js
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
```
|
||||
|
||||
### OpenSSL
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
输出应是标准 Base64,解码后正好 32 字节。把它直接保存为 GitHub Actions Secret `ENCRYPTION_KEY`,不要使用普通密码、UUID、示例值或在线随机字符串网页。
|
||||
|
||||
::: danger 加密密钥必须长期保管
|
||||
首次生成后,将 `ENCRYPTION_KEY` 保存到可信密码管理器。丢失密钥会导致 D1 中已有主机资料无法解密。当前版本不支持直接轮换,后续部署必须复用原值。
|
||||
:::
|
||||
|
||||
## 哪些值不是环境变量
|
||||
|
||||
不要手动创建以下同名变量:
|
||||
|
||||
- `DB`:D1 binding,由部署配置生成。
|
||||
- `SSH_SESSIONS`:Durable Object binding。
|
||||
- `ASSETS`:Worker 静态资源 binding。
|
||||
|
||||
`CONNECT_TIMEOUT_MS` 是普通变量,仓库默认值为 `10000`。除非确实需要调整 2,000 至 30,000 毫秒的建连超时,否则保持默认。
|
||||
|
||||
## 保存后的检查
|
||||
|
||||
1. GitHub Actions Secrets 列表中能看到三个 Secret 名称。
|
||||
2. 最新一次 `Deploy` 的 **Sync Worker secrets** 步骤成功。
|
||||
3. 访问正式域名并通过 Access 登录。
|
||||
4. 主机列表接口不再返回“管理员尚未配置 Zero Trust Access”。
|
||||
|
||||
运行时 Secret 不需要在每次 Action 中重填。workflow 会把 GitHub 中的固定值幂等同步给 Worker;尤其不要直接替换 `ENCRYPTION_KEY`,否则已有主机资料将无法解密。
|
||||
@@ -0,0 +1,80 @@
|
||||
# 常见部署问题
|
||||
|
||||
先找到失败发生在哪一层:GitHub Action、Cloudflare 资源、Access 身份、Worker Secret,还是 SSH 目标。不要通过关闭认证或放宽网络限制来绕过问题。
|
||||
|
||||
## Action 找不到账户或认证失败
|
||||
|
||||
检查:
|
||||
|
||||
- `CLOUDFLARE_ACCOUNT_ID` 是否保存为 GitHub Variable。
|
||||
- `CLOUDFLARE_API_TOKEN` 是否保存为 GitHub Secret。
|
||||
- Token 的 Account Resources 是否包含该账户。
|
||||
- Token 是否过期、撤销或复制不完整。
|
||||
|
||||
## D1 创建或 migration 失败
|
||||
|
||||
常见原因:
|
||||
|
||||
- Token 缺少 Account `D1: Edit`。
|
||||
- `D1_DATABASE_NAME` 含不支持字符。
|
||||
- 同一账户中存在名称冲突但权限不足以读取。
|
||||
- 上一次并发部署仍在运行。
|
||||
|
||||
不要手工把某个 D1 ID写进 `wrangler.toml`。部署脚本会按数据库名称解析 ID,并只写入 runner 的临时配置。
|
||||
|
||||
## 自定义域名绑定失败
|
||||
|
||||
确认:
|
||||
|
||||
- `CUSTOM_DOMAIN` 只有纯主机名。
|
||||
- 域名所在 Zone 已接入同一个 Cloudflare 账户。
|
||||
- Token 的 Zone Resources 包含这个 Zone。
|
||||
- Access 应用与 `CUSTOM_DOMAIN` 使用相同主机名。
|
||||
|
||||
## Action 成功,但打开后返回 503
|
||||
|
||||
确认最新一次 `Deploy` 包含并成功执行 **Sync Worker secrets**,然后检查:
|
||||
|
||||
- GitHub Actions Secrets 中存在 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD`、`ENCRYPTION_KEY`。
|
||||
- 三项名称拼写完全一致,且没有误存为 Variable。
|
||||
- Team Domain 和 AUD 来自当前自定义域名对应的 Access 应用。
|
||||
|
||||
Cloudflare Worker 的 Variables and Secrets 页面只用于核验三个名称是否已同步,不要在那里维护另一套值。
|
||||
|
||||
## 页面先登录,之后提示 Access 已失效
|
||||
|
||||
通常是 `ACCESS_AUD` 来自另一个应用,或 `ACCESS_TEAM_DOMAIN` 属于另一个 Zero Trust 组织。重新从当前自定义域名对应的 Self-hosted 应用复制 AUD。
|
||||
|
||||
## 主机能保存但连接超时
|
||||
|
||||
检查目标层:
|
||||
|
||||
- 主机名是否解析到公网地址。
|
||||
- SSH 服务端口是否正确。
|
||||
- 目标防火墙是否允许外部连接。
|
||||
- 云服务商安全组是否开放对应端口。
|
||||
- 目标是否只允许固定来源 IP。Cloudflare Workers 出站地址不适合传统固定 IP 白名单。
|
||||
|
||||
不要把 `CONNECT_TIMEOUT_MS` 调得很大来掩盖网络不可达。默认 10 秒足以区分正常连接与大多数路由问题。
|
||||
|
||||
## 私钥认证失败
|
||||
|
||||
当前只支持未加密 OpenSSH 格式的 Ed25519、RSA、ECDSA P-256/P-384/P-521 私钥。不支持:
|
||||
|
||||
- 带 passphrase 的加密私钥。
|
||||
- PEM 或 PKCS#8 私钥。
|
||||
- SSH Agent。
|
||||
- 需要多轮交互的 MFA 登录。
|
||||
|
||||
先在受信任终端用同一账号和密钥验证目标,再核对私钥格式。
|
||||
|
||||
## 怎样提交有效 Issue
|
||||
|
||||
提供:
|
||||
|
||||
- 失败所在步骤。
|
||||
- Action 步骤名称与脱敏错误信息。
|
||||
- 浏览器、操作系统和目标 SSH 服务类型。
|
||||
- 能稳定复现的最少步骤。
|
||||
|
||||
不要提供密码、私钥、API Token、Access JWT、Cookie、Team Domain、AUD、未脱敏 IP 或完整 Action 日志。
|
||||
@@ -0,0 +1,87 @@
|
||||
# 部署后验收
|
||||
|
||||
把“网页能打开”当作开始,而不是结束。下面的检查从身份边界到真实 SSH 功能逐步推进,便于在出现问题时定位到正确层级。
|
||||
|
||||
## 1. Access 入口
|
||||
|
||||
- 在未登录浏览器中访问正式域名,应先出现 Cloudflare Access。
|
||||
- 使用不在 Allow 策略中的邮箱,不能进入 EdgeSSH。
|
||||
- 使用管理员邮箱登录后,应进入主机总览。
|
||||
- 正式使用始终从自定义域名进入,不把 `workers.dev` 地址分享为入口。
|
||||
|
||||
## 2. 账号 API
|
||||
|
||||
登录后直接访问:
|
||||
|
||||
```text
|
||||
https://ssh.example.com/api/auth/me
|
||||
```
|
||||
|
||||
把域名替换为你的实际入口。应返回当前 Access 身份的受控信息,而不是 401、503 或 HTML 登录页。
|
||||
|
||||
若返回 503,优先检查三个 GitHub Actions Secret 是否已由最新一次 `Deploy` 成功同步。若返回身份失效,检查 Team Domain、AUD 与当前 Access 应用是否一致。
|
||||
|
||||
## 3. 主机资料
|
||||
|
||||
创建一台测试主机,建议填写:
|
||||
|
||||
- 一个清晰的显示名称。
|
||||
- 公网地址与 SSH 端口。
|
||||
- 低权限测试用户名。
|
||||
- 测试分组。
|
||||
- 密码或受支持的未加密 OpenSSH 私钥。
|
||||
|
||||
保存后刷新页面,确认记录仍然存在。再编辑非凭据字段,确认留空不会意外清除原凭据。
|
||||
|
||||
<ScreenshotPlaceholder
|
||||
title="部署完成后的主机总览"
|
||||
description="请使用保留地址或完全脱敏的测试主机截图。隐藏真实 IP、用户名、分组、城市和任何可识别基础设施的信息。"
|
||||
filename="10-edgessh-host-list.png"
|
||||
src="/images/edgessh-dashboard-demo.png"
|
||||
alt="使用演示主机数据的 EdgeSSH 主机总览"
|
||||
caption="EdgeSSH 主机总览演示;画面使用文档保留地址,不代表真实生产主机。"
|
||||
/>
|
||||
|
||||
## 4. 首次连接与指纹
|
||||
|
||||
首次连接时,EdgeSSH 会展示服务器 SHA-256 指纹。不要直接确认:
|
||||
|
||||
1. 从服务器控制台或可信渠道取得真实主机指纹。
|
||||
2. 逐字比较 EdgeSSH 显示值。
|
||||
3. 一致后再确认并发送 SSH 凭据。
|
||||
|
||||
指纹不一致可能意味着服务器重装、地址指向了另一台机器,或连接遭到拦截。先查明原因,不要通过删除指纹来绕过。
|
||||
|
||||
## 5. 终端
|
||||
|
||||
- Shell 可以正常交互并显示中文。
|
||||
- 调整浏览器大小后,终端列数与行数同步。
|
||||
- 全屏进入与退出正常。
|
||||
- 返回主机总览后,会话按预期断开。
|
||||
- 目标使用 GB18030 或 Big5 时,切换对应终端编码验证显示。
|
||||
|
||||
## 6. SFTP
|
||||
|
||||
使用专门的测试目录验收:
|
||||
|
||||
1. 浏览目录。
|
||||
2. 新建子目录。
|
||||
3. 上传一个小文件。
|
||||
4. 下载并比较文件内容。
|
||||
5. 重命名文件。
|
||||
6. 删除文件与空目录。
|
||||
|
||||
不要使用生产关键目录做首次测试。当前单文件上传与下载上限为 64 MiB,非空目录不能直接删除。
|
||||
|
||||
## 7. 进程与系统信息
|
||||
|
||||
- 能显示 CPU、负载、内存与 Swap。
|
||||
- 能加载进程列表。
|
||||
- 能识别常见 Linux 发行版、版本与架构。
|
||||
- 辅助面板失败时,主终端会话仍能明确显示错误,不泄露内部堆栈。
|
||||
|
||||
## 8. 清理测试数据
|
||||
|
||||
删除本次创建的临时主机记录和目标服务器上的测试文件。不要清空整个 D1,也不要删除 `d1_migrations` 表。
|
||||
|
||||
完成所有检查后,才可以把部署标记为可正常使用。
|
||||
@@ -0,0 +1,103 @@
|
||||
# 配置 Cloudflare Zero Trust Access
|
||||
|
||||
EdgeSSH 不提供本地用户名、密码或注册系统。生产入口必须先经过 Cloudflare Access,Worker 还会再次验证 Access 注入的 JWT。
|
||||
|
||||
::: warning 使用明确身份
|
||||
推荐“明确邮箱 + Allow”。不要使用 `Everyone`、`Bypass`,也不要只按登录方式放行所有能够接收验证码的人。
|
||||
:::
|
||||
|
||||
## 1. 准备登录方式
|
||||
|
||||
个人部署可以使用 One-time PIN 邮件验证码。若 Zero Trust 组织中没有该选项:
|
||||
|
||||
1. 进入 **Zero Trust → Integrations → Identity providers**。
|
||||
2. 选择 **Add new identity provider**。
|
||||
3. 添加 **One-time PIN** 并保存。
|
||||
|
||||
如果已经配置 Google、GitHub、Microsoft Entra ID 或其他身份提供程序,可以继续使用已有方式。
|
||||
|
||||
## 2. 创建 Self-hosted 应用
|
||||
|
||||
1. 进入 **Zero Trust → Access controls → Applications**。
|
||||
2. 选择 **Create new application**。
|
||||
3. 选择 **Self-hosted and private**。
|
||||
4. 添加 Public hostname。
|
||||
5. Application name 填写 `EdgeSSH`。
|
||||
6. Public hostname 填写与 GitHub `CUSTOM_DOMAIN` 完全相同的域名。
|
||||
|
||||
<ScreenshotPlaceholder
|
||||
title="EdgeSSH Access 应用"
|
||||
description="请截取应用名称、Self-hosted 类型和 Public hostname。隐藏 Team Domain 之外的组织信息。"
|
||||
filename="06-zero-trust-application.png"
|
||||
/>
|
||||
|
||||
## 3. 添加 Allow 策略
|
||||
|
||||
在应用的 Access policies 中创建策略:
|
||||
|
||||
| 项目 | 推荐值 |
|
||||
| --- | --- |
|
||||
| Policy name | `EdgeSSH Admin` |
|
||||
| Action | `Allow` |
|
||||
| Rule type | `Include` |
|
||||
| Selector | `Emails` |
|
||||
| Value | 你的完整管理员邮箱 |
|
||||
|
||||
需要多名管理员时,逐个添加明确邮箱。只有在你确实希望整个企业邮箱域都能管理服务器时,才使用整域授权。
|
||||
|
||||
<ScreenshotPlaceholder
|
||||
title="仅允许管理员邮箱的 Access 策略"
|
||||
description="请截取 Action、Include、Emails 三项。邮箱地址请打码,只保留结构可辨认。"
|
||||
filename="07-zero-trust-policy.png"
|
||||
/>
|
||||
|
||||
## 4. 获取 Team Domain
|
||||
|
||||
进入 Zero Trust 设置,找到 Team name 或 Team domain。保存为 GitHub Actions Secret `ACCESS_TEAM_DOMAIN` 时只保留域名:
|
||||
|
||||
```text
|
||||
my-team.cloudflareaccess.com
|
||||
```
|
||||
|
||||
不要带 `https://`、路径或末尾斜杠。
|
||||
|
||||
## 5. 获取 Application Audience
|
||||
|
||||
1. 回到 **Access controls → Applications**。
|
||||
2. 打开刚创建的 EdgeSSH 应用。
|
||||
3. 在 Additional settings 中找到 **Application Audience (AUD) Tag**。
|
||||
4. 复制完整值,后续保存为 `ACCESS_AUD`。
|
||||
|
||||
不要把应用 ID、Client ID 或策略 ID 当成 AUD。
|
||||
|
||||
## 6. 验证 Access 本身
|
||||
|
||||
在运行 `Deploy` workflow 前,也可以先访问自定义域名验证外层策略:
|
||||
|
||||
- 未登录时应先出现 Cloudflare Access 登录页。
|
||||
- 不在 Allow 策略内的邮箱不能进入。
|
||||
- 允许的邮箱能够完成认证。
|
||||
|
||||
<ScreenshotPlaceholder
|
||||
title="正式域名的 Access 登录页"
|
||||
description="请截取浏览器地址栏中的 EdgeSSH 自定义域名和 Access 登录界面。邮箱、验证码、Cookie 与重定向参数必须隐藏。"
|
||||
filename="09-access-login.png"
|
||||
/>
|
||||
|
||||
## 常见问题
|
||||
|
||||
### One-time PIN 收不到邮件
|
||||
|
||||
先确认策略中的 Emails 与登录邮箱完全一致。Cloudflare 不会向未被策略允许的邮箱发送验证码,但页面可能仍显示已发送,避免泄露访问名单。
|
||||
|
||||
### 登录后提示 Access 登录已失效
|
||||
|
||||
通常是以下三项没有对应同一个应用:
|
||||
|
||||
- 正在访问的自定义域名。
|
||||
- `ACCESS_TEAM_DOMAIN` 所属 Zero Trust 组织。
|
||||
- `ACCESS_AUD` 所属 Self-hosted 应用。
|
||||
|
||||
### 页面没有出现 Access 登录
|
||||
|
||||
确认 Access 应用 Public hostname 与 `CUSTOM_DOMAIN` 完全一致。不要把 `workers.dev` 诊断地址当作正式入口。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 贡献指南
|
||||
|
||||
欢迎通过 Issue 反馈问题,或通过 Pull Request 提交改进。
|
||||
|
||||
## 提交前
|
||||
|
||||
运行完整检查:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
如果修改了文档,再启动本地预览检查桌面和移动端:
|
||||
|
||||
```bash
|
||||
npm run docs:dev
|
||||
```
|
||||
|
||||
## 变更范围
|
||||
|
||||
- 一个文件尽量承担单一职责。
|
||||
- 优先沿用现有模块、类型与错误响应结构。
|
||||
- 对外接口返回统一错误,不暴露堆栈、源码或内部数据。
|
||||
- Worker 请求链路中的 I/O 必须异步等待,避免阻塞计算。
|
||||
- 数据库迁移只追加必要 schema,不在普通部署中清空生产数据。
|
||||
|
||||
## 测试真实功能
|
||||
|
||||
涉及 SSH、SFTP、进程面板或操作系统探测时,使用你有权访问的测试服务器。Pull Request 中明确写出已验证的范围;没有真实目标时,不要声称真实连接已通过。
|
||||
|
||||
## 安全报告
|
||||
|
||||
Issue 和 Pull Request 中不要上传:
|
||||
|
||||
- 密码或私钥。
|
||||
- Cloudflare API Token 或 Worker Secret。
|
||||
- Access JWT、Cookie、AUD 或 Team Domain。
|
||||
- 未脱敏的服务器 IP、域名或完整日志。
|
||||
|
||||
## 上游与许可证
|
||||
|
||||
EdgeSSH 基于 [Worker Web SSH](https://github.com/cmliu/CF-Workers-WebSSH) 深度开发,采用 Apache License 2.0。修改与分发时请保留适用的版权和许可证声明。
|
||||
@@ -0,0 +1,59 @@
|
||||
# 部署文档站到 Cloudflare Pages
|
||||
|
||||
VitePress 文档通过独立的 `Deploy docs` GitHub Actions workflow 发布到 Cloudflare Pages。它不会写入 EdgeSSH Worker 的 `dist/`,也不会访问 D1 或运行时 Secret。
|
||||
|
||||
## 首次部署
|
||||
|
||||
文档 workflow 复用两个部署凭据:
|
||||
|
||||
- GitHub Variable `CLOUDFLARE_ACCOUNT_ID`。
|
||||
- GitHub Secret `CLOUDFLARE_API_TOKEN`。
|
||||
|
||||
API Token 需要 Account `Cloudflare Pages: Edit`。可选 Variable `DOCS_PROJECT_NAME` 控制 Pages 项目名,默认 `edgessh-docs`。
|
||||
|
||||
进入:
|
||||
|
||||
```text
|
||||
Actions → Deploy docs → Run workflow
|
||||
```
|
||||
|
||||
首次运行会按名称检查 Pages 项目,不存在时创建,存在时直接复用。随后构建 `docs/.vitepress/dist` 并发布到生产分支 `main`。
|
||||
|
||||
## 添加多个自定义域名
|
||||
|
||||
首次成功后打开 Cloudflare Dashboard:
|
||||
|
||||
```text
|
||||
Workers & Pages → edgessh-docs → Custom domains
|
||||
```
|
||||
|
||||
选择 **Set up a custom domain**,逐个添加文档域名。Pages 项目与 EdgeSSH Worker 相互独立,因此可以为文档站绑定多个域名,而不修改 Worker 的 `CUSTOM_DOMAIN`。
|
||||
|
||||
<ScreenshotPlaceholder
|
||||
title="Cloudflare Pages 自定义域名"
|
||||
description="请截取 Pages 项目的 Custom domains 页面和添加入口。隐藏与文档站无关的其他项目和域名。"
|
||||
filename="11-pages-custom-domain.png"
|
||||
/>
|
||||
|
||||
## 自动触发
|
||||
|
||||
推送到 `main` 且改动以下路径时,文档 workflow 自动运行:
|
||||
|
||||
- `docs/**`
|
||||
- `package.json` 或 `package-lock.json`
|
||||
- 文档部署 workflow 与 Pages 准备脚本
|
||||
|
||||
应用 Worker 的 `Deploy` workflow 与文档的 `Deploy docs` workflow 使用不同并发锁和不同产物目录,不会互相覆盖。纯 `docs/**`、文档 workflow 或 Pages 准备脚本改动也被 Worker workflow 排除,不会顺带发布生产 EdgeSSH 应用。
|
||||
|
||||
## 本地预览
|
||||
|
||||
```bash
|
||||
npm run docs:dev
|
||||
```
|
||||
|
||||
生产构建检查:
|
||||
|
||||
```bash
|
||||
npm run docs:build
|
||||
npm run docs:preview
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
# 本地开发
|
||||
|
||||
本地开发需要 Node.js 22.12.0 或更高版本,以及 npm。
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
git clone https://github.com/aozorae/EdgeSSH.git
|
||||
cd EdgeSSH
|
||||
npm ci
|
||||
```
|
||||
|
||||
## 准备本地变量
|
||||
|
||||
```bash
|
||||
cp .env.example .dev.vars
|
||||
```
|
||||
|
||||
在 `.dev.vars` 中填写开发用的 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD` 与 `ENCRYPTION_KEY`。本地仍会执行真实 Access 身份校验,不提供匿名绕过。
|
||||
|
||||
初始化本地 D1:
|
||||
|
||||
```bash
|
||||
npx wrangler d1 migrations apply DB --local
|
||||
```
|
||||
|
||||
## 启动 Worker
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Wrangler 会按 `wrangler.toml` 构建前端并启动本地 Worker。
|
||||
|
||||
需要前端热更新时,在另一个终端运行:
|
||||
|
||||
```bash
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
Vite 默认位于 `http://localhost:5173`,并把 `/api` 代理到本地 Worker 的 `8787` 端口。
|
||||
|
||||
## 启动文档站
|
||||
|
||||
```bash
|
||||
npm run docs:dev
|
||||
```
|
||||
|
||||
VitePress 会输出本地地址。文档站不需要 D1、Access 或 SSH 目标。
|
||||
|
||||
## 检查命令
|
||||
|
||||
| 命令 | 用途 |
|
||||
| --- | --- |
|
||||
| `npm run typecheck` | 检查 Worker 与前端类型 |
|
||||
| `npm test` | 运行 Node 测试 |
|
||||
| `npm run build:web` | 构建 EdgeSSH 前端 |
|
||||
| `npm run docs:build` | 构建 VitePress 文档 |
|
||||
| `npm run check` | 类型、测试、文档与 Wrangler dry-run |
|
||||
|
||||
涉及连接、SFTP 或进程面板的改动,最终仍要使用真实且已授权的 SSH 目标验收。
|
||||
@@ -0,0 +1,22 @@
|
||||
# SSH 工作台主题适配
|
||||
|
||||
## 维护边界
|
||||
|
||||
主机总览继续由 `frontend/src/dashboard.css` 和 `dashboard.ts` 管理,布局、地球和主机列表不因工作台换肤而调整。
|
||||
|
||||
`frontend/src/workspace-theme.css` 是 EdgeSSH 自有的薄适配层。在 `main.ts` 中紧跟上游 `style.css` 导入,复用总览的 `--home-*` 色彩与字体,将它们映射到上游既有的 `--bg`、`--panel`、`--accent` 等变量。组件覆盖限定在 `#app` 内,避免影响总览。
|
||||
|
||||
- 默认浅色;原来的 `workers-webssh.theme` 偏好和深色切换继续生效。
|
||||
- 终端输出始终保留上游深色背景和 ANSI 调色板。绿色输出不是品牌色,不应全局替换;成功状态保留绿色,危险操作保留红色。
|
||||
- 工具栏、连接表单、文件/进程面板、会话日志与弹窗使用统一外壳;不复制业务组件,不改变 DOM ID、状态类、事件绑定或协议。
|
||||
- 小屏只通过 CSS 将资源指标放到工具栏第二行;原有文件树、分栏、全屏和面板开关继续由上游管理。
|
||||
|
||||
## 合并上游时
|
||||
|
||||
1. 正常合并上游的 `style.css`、`main.ts` 和业务模块,不把品牌样式搬回这些文件。
|
||||
2. 保留 `main.ts` 里最后导入的 `workspace-theme.css`,以及 HTML 的浅色默认值。
|
||||
3. 若上游改名或删除 CSS 变量/组件类,在适配层更新对应映射,而不是维护第二份组件。
|
||||
4. 执行 `npm run check`。主题的静态契约测试用于提醒入口或关键选择器漂移,不代替浏览器验收。
|
||||
5. 浏览器复核浅/深色、连接表单、空终端、已连接终端、文件/进程/日志、指纹与危险操作弹窗;检查 320、375、414、768px 与桌面。
|
||||
|
||||
本次不修改 SSH/SFTP/进程逻辑,并不意味着未来合并永远无冲突。适配层依赖的上游 CSS 变量、类名或结构变化时,仍需要逐项验证。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 认识 EdgeSSH
|
||||
|
||||
EdgeSSH 是运行在 Cloudflare Workers 上的个人 SSH 工作台。部署完成后,你可以从浏览器管理主机、打开交互终端、使用 SFTP 管理文件,并查看远端进程与系统资源。
|
||||
|
||||
## 它解决什么问题
|
||||
|
||||
传统 WebSSH 通常还要维护一台中转服务器。EdgeSSH 把前端静态资源、身份校验、主机资料和 SSH 客户端放在 Cloudflare 边缘运行:
|
||||
|
||||
```text
|
||||
浏览器
|
||||
├─ 主机总览与地球视图
|
||||
├─ xterm.js 交互终端
|
||||
├─ SFTP 文件管理
|
||||
└─ 进程与系统信息
|
||||
│ HTTPS / WebSocket
|
||||
▼
|
||||
Cloudflare Access → Worker → Durable Object → TCP Socket → SSH 服务器
|
||||
│
|
||||
└─ D1:每个身份独立的加密主机资料
|
||||
```
|
||||
|
||||
浏览器不会直接连接 SSH 服务器。每个连接会话由 Durable Object 隔离,Worker 通过 Cloudflare TCP Sockets 连接目标的公网 SSH 端口。
|
||||
|
||||
## 主要能力
|
||||
|
||||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
| 主机管理 | 保存地址、端口、用户名、分组和认证信息;支持搜索、编辑与删除 |
|
||||
| 地球视图 | 根据公网 IP 显示主机位置;位置不代表在线状态 |
|
||||
| 浏览器终端 | 交互式 Shell、窗口尺寸同步、全屏、会话日志与多种终端编码 |
|
||||
| SFTP | 浏览目录、上传、下载、新建目录、重命名与删除空目录 |
|
||||
| 进程面板 | 查看 CPU、负载、内存、Swap 与进程列表 |
|
||||
| 系统识别 | 通过 SSH 探测操作系统、版本与架构 |
|
||||
|
||||
## 凭据怎样保存
|
||||
|
||||
主机资料通过 AES-256-GCM 加密后写入 D1。密文绑定 Access 用户身份与记录 ID,列表接口不会返回密码或私钥;建立连接时才在 Worker 内解密,并只把必要数据放入当前会话。
|
||||
|
||||
::: warning 这不是端到端加密
|
||||
Worker 是实际的 SSH 客户端,会在会话内处理明文凭据。只把 EdgeSSH 部署到你信任的 Cloudflare 账户,并为 SSH 使用最小权限账号或密钥。
|
||||
:::
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
1. 阅读[部署前准备](/guide/requirements),确认账户、域名和目标服务器都满足条件。
|
||||
2. 按[GitHub Actions 完整部署流程](/deploy/actions)完成首次发布。
|
||||
3. 按[部署后验收](/deploy/verification)检查 Access、主机 API 和真实 SSH 连接。
|
||||
4. 使用前阅读[支持范围与限制](/reference/limits),避免把未支持的认证方式当作配置问题。
|
||||
@@ -0,0 +1,61 @@
|
||||
# 部署前准备
|
||||
|
||||
先完成本页清单,再打开多个控制台操作。这样可以避免部署到一半才发现域名、权限或 SSH 目标不满足要求。
|
||||
|
||||
## 必备资源
|
||||
|
||||
- 一个 GitHub 账户,用于 Fork 仓库和运行 Actions。
|
||||
- 一个可使用 Workers、Durable Objects、D1 与 Pages 的 Cloudflare 账户。
|
||||
- 一个已经接入同一 Cloudflare 账户的域名,例如 `example.com`。
|
||||
- 一个准备分配给 EdgeSSH 的子域名,例如 `ssh.example.com`。
|
||||
- 一台你有权访问、能够从公网连接的 SSH 服务器。
|
||||
- 一个可以接收 Cloudflare Access 登录邮件的管理员邮箱,或已有的身份提供程序。
|
||||
|
||||
::: info Node.js 只影响本地开发
|
||||
只用 GitHub Actions 部署时,不需要在自己的电脑安装 Node.js。需要本地开发时,使用 Node.js 22.12.0 或更高版本。
|
||||
:::
|
||||
|
||||
## 域名要求
|
||||
|
||||
正式入口应使用受 Cloudflare Access 保护的自定义域名。后续教程统一使用:
|
||||
|
||||
```text
|
||||
ssh.example.com
|
||||
```
|
||||
|
||||
替换为你自己的域名即可。填写 GitHub Variable 时只写主机名,不要带 `https://`、端口、路径或末尾斜杠。
|
||||
|
||||
## SSH 目标要求
|
||||
|
||||
目标必须解析到公网 IP。EdgeSSH 会拒绝回环地址、私有地址、链路本地地址和其他不应从公网访问的目标,以降低 SSRF 与 DNS 重绑定风险。
|
||||
|
||||
建议为验收准备一个专用的低权限 SSH 账号,并确认:
|
||||
|
||||
- SSH 服务监听公网可达端口。
|
||||
- 防火墙允许 Cloudflare Workers 发起连接。
|
||||
- 你已准备密码或一个受支持的未加密 OpenSSH 私钥。
|
||||
- 你知道首次连接时应该核对的服务器指纹。
|
||||
|
||||
## 正式部署必须填写的 6 个值
|
||||
|
||||
这 6 项全部在 GitHub 仓库的 **Secrets and variables → Actions** 中填写。完成之前不要运行 `Deploy` workflow:
|
||||
|
||||
| 名称 | 保存位置 | 是否敏感 |
|
||||
| --- | --- | --- |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | GitHub Actions Variable | 否 |
|
||||
| `CUSTOM_DOMAIN` | GitHub Actions Variable | 否 |
|
||||
| `CLOUDFLARE_API_TOKEN` | GitHub Actions Secret | 是 |
|
||||
| `ACCESS_TEAM_DOMAIN` | GitHub Actions Secret,自动同步为 Worker Secret | 是 |
|
||||
| `ACCESS_AUD` | GitHub Actions Secret,自动同步为 Worker Secret | 是 |
|
||||
| `ENCRYPTION_KEY` | GitHub Actions Secret,自动同步为 Worker Secret | 是 |
|
||||
|
||||
`WORKER_NAME` 与 `D1_DATABASE_NAME` 是可选项,通常不要填写。默认名称分别是 `edgessh` 与 `edgessh-accounts`。
|
||||
|
||||
## 不要提前做的事
|
||||
|
||||
- 不要手动创建 D1。Action 会按名称创建或复用数据库。
|
||||
- 不要修改 `wrangler.toml` 中的全零 D1 ID。它是版本库中的安全占位值。
|
||||
- 不要把运行时 Secret 保存为 GitHub Variable,也不要写入源码、`wrangler.toml` 或 Issue。
|
||||
- 不要先生成多个 `ENCRYPTION_KEY`。首次确定后要长期复用同一个值。
|
||||
|
||||
准备完成后,进入[GitHub Actions 完整部署流程](/deploy/actions)。
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: EdgeSSH
|
||||
text: 从 Fork 到上线,可逐项核对
|
||||
tagline: 使用 GitHub Actions 创建或复用 Cloudflare 资源。无需手写 D1 ID,也无需在本地执行生产部署命令。
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 使用 Actions 部署
|
||||
link: /deploy/actions
|
||||
- theme: alt
|
||||
text: 先了解 EdgeSSH
|
||||
link: /guide/overview
|
||||
---
|
||||
|
||||
<section class="home-workbench">
|
||||
<figure class="home-capture">
|
||||
<img src="/images/edgessh-dashboard-demo.png" alt="EdgeSSH 主机总览与地球视图" width="1600" height="1000" fetchpriority="high" />
|
||||
<figcaption>主机资料加密保存在 D1;从总览进入终端、SFTP 和进程面板。</figcaption>
|
||||
</figure>
|
||||
<div class="home-workbench__note">
|
||||
<strong>打开浏览器,连接你的服务器。</strong>
|
||||
<p>EdgeSSH 运行于 Cloudflare Workers。浏览器只连接 Worker,由独立 Durable Object 会话通过 TCP Socket 连接公网 SSH 服务。</p>
|
||||
<a class="home-link" href="/reference/security">查看安全边界</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="home-path">
|
||||
<h2>一条可以重复执行的部署路径</h2>
|
||||
<div class="deploy-track">
|
||||
<div>
|
||||
<span>01</span>
|
||||
<strong>准备 Cloudflare</strong>
|
||||
<p>接入域名,创建最小权限 API Token,并配置 Access 应用。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>02</span>
|
||||
<strong>填写 GitHub</strong>
|
||||
<p>保存账户 ID、自定义域名与四项 Secret,名称通常保持默认。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>03</span>
|
||||
<strong>运行 Deploy</strong>
|
||||
<p>Action 自动检查项目、创建或复用 D1、执行迁移并发布 Worker。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>04</span>
|
||||
<strong>验证自动同步</strong>
|
||||
<p>确认 Action 已同步 Access 参数与固定加密密钥,再完成验收。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="home-boundary">
|
||||
<div>
|
||||
<h2>先明确边界,再保存凭据</h2>
|
||||
</div>
|
||||
<div>
|
||||
<p>EdgeSSH 面向个人管理员,不提供本地注册或匿名入口。Cloudflare Access 负责身份认证,Worker 在应用内再次校验 Access JWT。它不是端到端加密:Worker 在建立 SSH 会话时会处理明文凭据,因此 Cloudflare 账户和 Worker Secret 管理权限都属于信任边界。</p>
|
||||
<a class="home-link" href="/deploy/actions">开始完整部署</a>
|
||||
</div>
|
||||
</section>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,56 @@
|
||||
# 配置项
|
||||
|
||||
EdgeSSH 的配置分为 GitHub Actions 部署输入、由 workflow 同步的 Cloudflare Worker Secret、普通变量和资源 binding。把它们放在正确位置,才能避免泄漏或同名冲突。
|
||||
|
||||
## 正式部署必填
|
||||
|
||||
下面 6 项全部保存在 GitHub Actions。2 个 Variable 可见,4 个 Secret 保存后不再显示:
|
||||
|
||||
| 名称 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Variable | 目标 Cloudflare 账户 |
|
||||
| `CUSTOM_DOMAIN` | Variable | 受 Access 保护的自定义域名纯主机名 |
|
||||
| `CLOUDFLARE_API_TOKEN` | Secret | Workers、D1、Zone 部署凭据 |
|
||||
| `ACCESS_TEAM_DOMAIN` | Secret | 同步为 Worker Secret |
|
||||
| `ACCESS_AUD` | Secret | 同步为 Worker Secret |
|
||||
| `ENCRYPTION_KEY` | Secret | 同步为 Worker Secret,后续部署必须保持不变 |
|
||||
|
||||
## 可选 GitHub Actions Variable
|
||||
|
||||
| 名称 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `WORKER_NAME` | `edgessh` | Worker 名称 |
|
||||
| `D1_DATABASE_NAME` | `<WORKER_NAME>-accounts` | D1 名称 |
|
||||
| `DOCS_PROJECT_NAME` | `edgessh-docs` | VitePress Pages 项目名,只供 `Deploy docs` 使用 |
|
||||
|
||||
## Worker Secret
|
||||
|
||||
| 名称 | 必需 | 格式 |
|
||||
| --- | --- | --- |
|
||||
| `ACCESS_TEAM_DOMAIN` | 是 | `my-team.cloudflareaccess.com`,无协议或路径 |
|
||||
| `ACCESS_AUD` | 是 | 当前 Self-hosted Access 应用的 AUD Tag |
|
||||
| `ENCRYPTION_KEY` | 是 | 解码后 32 字节的标准 Base64 |
|
||||
|
||||
生产 Secret 在 GitHub Actions Secrets 中配置,由 `Deploy` workflow 经标准输入同步到 Cloudflare Worker。Worker 的 **Variables and Secrets** 页面只用于核验同步结果;不要在那里维护另一套值,也不要把 Secret 写入 `wrangler.toml` 或源码。
|
||||
|
||||
## 普通变量
|
||||
|
||||
| 名称 | 默认值 | 范围 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `CONNECT_TIMEOUT_MS` | `10000` | `2000` 至 `30000` | TCP 建连超时,单位毫秒 |
|
||||
|
||||
## 资源 binding
|
||||
|
||||
| 名称 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `DB` | D1 | 加密主机资料与 migration 状态 |
|
||||
| `SSH_SESSIONS` | Durable Object | 每会话 SSH 客户端 |
|
||||
| `ASSETS` | Static Assets | Vite 构建后的 EdgeSSH 前端 |
|
||||
|
||||
这些 binding 由 Wrangler 配置生成,不要在 Variables and Secrets 中创建同名值。
|
||||
|
||||
## 本地变量
|
||||
|
||||
本地开发把 `.env.example` 复制为 `.dev.vars`,再填入开发值。`.dev.vars` 已被 Git 忽略,不应提交。
|
||||
|
||||
本地仍执行 Access JWT 校验,没有匿名开发绕过开关。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 支持范围与限制
|
||||
|
||||
部署前先核对下表。未支持的能力不会因为调整 Cloudflare 权限或增大超时而出现。
|
||||
|
||||
## 当前支持
|
||||
|
||||
| 类别 | 支持情况 |
|
||||
| --- | --- |
|
||||
| SSH | SSH 2.0、交互式 Shell、PTY、窗口尺寸同步、Keepalive |
|
||||
| 密码认证 | password 与单密码提示 keyboard-interactive |
|
||||
| 私钥 | 未加密 OpenSSH Ed25519、RSA、ECDSA P-256/P-384/P-521 |
|
||||
| 文件 | SFTP v3,单文件上传与下载上限 64 MiB |
|
||||
| 目录 | 浏览、新建、重命名、删除空目录 |
|
||||
| 主机管理 | 每个 Access 身份最多 200 台 |
|
||||
| 终端编码 | UTF-8、GB18030、Big5,取决于浏览器支持 |
|
||||
| 网络目标 | 公网地址;Cloudflare Workers 不允许出站 TCP 25 |
|
||||
|
||||
## 暂不支持
|
||||
|
||||
- 带 passphrase 的加密私钥。
|
||||
- PEM 或 PKCS#8 私钥。
|
||||
- SSH Agent。
|
||||
- 多因素或多轮 keyboard-interactive。
|
||||
- SCP。
|
||||
- 端口转发。
|
||||
- ProxyJump。
|
||||
- SSH 压缩。
|
||||
- 会话内 rekey。
|
||||
- 团队共享主机库。
|
||||
- 本地账号、密码找回或匿名访问。
|
||||
- 递归删除非空目录。
|
||||
|
||||
## 行为边界
|
||||
|
||||
- 地球位置不表示在线状态,也不显示伪造的延迟或在线数。
|
||||
- 同一城市的多个点位可能接近,主机列表始终是完整入口。
|
||||
- Worker 处理明文 SSH 凭据,因此不是端到端加密。
|
||||
- `workers.dev` 可用于诊断,但正式使用应从 Access 保护的自定义域名进入。
|
||||
- 同一个固定来源 IP 白名单通常不适用于 Cloudflare Workers 出站连接。
|
||||
|
||||
需要新能力时,先在 GitHub 提交具体使用场景。不要通过删除公网地址校验、Access 校验或主机指纹确认来实现快捷绕过。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 截图清单
|
||||
|
||||
文档已复用本地 EdgeChat 文档中的通用界面截图,并保留确实需要 EdgeSSH 实际环境的占位。后续只需补齐下表中标记为“待补”的项目。
|
||||
|
||||
| 状态 | 文件名 | 页面 | 画面重点 |
|
||||
| --- | --- | --- | --- |
|
||||
| 已复用 | `01-fork-repository.png` | Actions 部署 | Fork 入口 |
|
||||
| 已复用 | `02-cloudflare-api-token-permissions.png` | API Token | Token 模板入口 |
|
||||
| 已复用 | `03-github-actions-variables.png` | Actions 部署 | Secrets and variables 入口 |
|
||||
| 已复用 | `04-run-deploy-workflow.png` | Actions 部署 | Run workflow 与 main 分支 |
|
||||
| 待补 | `05-deploy-success.png` | Actions 部署 | EdgeSSH 绿色成功状态与关键步骤 |
|
||||
| 待补 | `06-zero-trust-application.png` | Zero Trust | EdgeSSH Self-hosted 类型与域名 |
|
||||
| 待补 | `07-zero-trust-policy.png` | Zero Trust | Allow、Include、Emails |
|
||||
| 已复用 | `08-github-actions-secrets.png` | Worker Secret | Repository secrets 入口 |
|
||||
| 待补 | `09-access-login.png` | Zero Trust | EdgeSSH 正式域名与登录页 |
|
||||
| 已有 | `10-edgessh-host-list.png` | 验收 | 使用保留地址的主机总览演示 |
|
||||
| 待补 | `11-pages-custom-domain.png` | 文档站部署 | Pages 自定义域名入口 |
|
||||
|
||||
## 截图规范
|
||||
|
||||
- 使用 1440px 或更宽的桌面视口,保留关键面包屑和页面标题。
|
||||
- 只截与当前步骤有关的区域,不截完整桌面。
|
||||
- API Token、Secret、Access AUD、Cookie、邮箱、真实 IP 和仓库私密信息必须打码。
|
||||
- 不要用模糊覆盖关键按钮;敏感值可以使用纯色实块遮盖。
|
||||
- 使用 PNG,保持界面文字清晰。
|
||||
- Cloudflare 或 GitHub 改版后,优先保证概念与字段名可辨认,不要求像素级复刻旧界面。
|
||||
|
||||
## 接入示例
|
||||
|
||||
原占位:
|
||||
|
||||
```md
|
||||
<ScreenshotPlaceholder
|
||||
title="Fork 仓库"
|
||||
description="说明截图范围"
|
||||
filename="01-fork-repository.png"
|
||||
/>
|
||||
```
|
||||
|
||||
添加图片后:
|
||||
|
||||
```md
|
||||
<ScreenshotPlaceholder
|
||||
title="Fork 仓库"
|
||||
description="说明截图范围"
|
||||
filename="01-fork-repository.png"
|
||||
src="/screenshots/01-fork-repository.png"
|
||||
alt="GitHub 上的 EdgeSSH Fork 页面"
|
||||
/>
|
||||
```
|
||||
|
||||
提交前运行 `npm run docs:build`,确认没有断链或资源错误。
|
||||
@@ -0,0 +1,73 @@
|
||||
# 架构与安全边界
|
||||
|
||||
EdgeSSH 让个人管理员通过浏览器访问 SSH,但它不会把 Worker 变成“不可信中转”。理解信任边界,是决定是否保存生产凭据的前提。
|
||||
|
||||
## 请求路径
|
||||
|
||||
```text
|
||||
Cloudflare Access
|
||||
│ 注入并签名 Access JWT
|
||||
▼
|
||||
Worker
|
||||
├─ 校验签名、issuer、audience、有效期、sub 与邮箱
|
||||
├─ API:按 Access sub 读取当前身份的数据
|
||||
├─ D1:读取或写入 AES-256-GCM 密文
|
||||
└─ 一次性会话票据
|
||||
▼
|
||||
Durable Object
|
||||
├─ SSH 传输、认证与终端通道
|
||||
├─ SFTP 辅助通道
|
||||
└─ 进程与系统信息通道
|
||||
▼
|
||||
公网 SSH 服务器
|
||||
```
|
||||
|
||||
## 身份边界
|
||||
|
||||
EdgeSSH 不信任单独的邮箱请求头。Worker 使用 `jose` 验证 `Cf-Access-Jwt-Assertion`,并检查:
|
||||
|
||||
- RS256 签名。
|
||||
- Issuer 是否属于配置的 Team Domain。
|
||||
- Audience 是否匹配当前 Access 应用。
|
||||
- 有效期。
|
||||
- 用户 `sub` 与邮箱。
|
||||
|
||||
主机 API、会话票据和各类 WebSocket 附着都要求有效身份。
|
||||
|
||||
## 数据加密
|
||||
|
||||
每条主机记录绑定 Access `sub`。Worker 使用 AES-256-GCM 加密完整主机资料,包括地址、密码或私钥、服务器指纹和位置。
|
||||
|
||||
- 每次加密使用随机 96-bit IV。
|
||||
- AAD 绑定账户和记录 ID。
|
||||
- 数据库存储 `v1.<base64 IV>.<base64 ciphertext + tag>` 格式密文。
|
||||
- 列表响应不返回连接凭据。
|
||||
|
||||
`ENCRYPTION_KEY` 丢失后,现有密文无法恢复。直接替换 Secret 不会自动重加密数据。
|
||||
|
||||
## 不是端到端加密
|
||||
|
||||
Worker 是 SSH 客户端,需要在内存中处理明文连接凭据。以下主体属于信任边界:
|
||||
|
||||
- 可以修改 Worker 代码并部署的人。
|
||||
- 可以读取或修改 Worker Secret 的人。
|
||||
- 可以修改 GitHub Actions 部署凭据的人。
|
||||
- Cloudflare 账户本身。
|
||||
|
||||
因此,EdgeSSH 适合部署在个人或明确受控的账户中,不应把未知第三方部署当作凭据保险箱。
|
||||
|
||||
## 目标地址限制
|
||||
|
||||
Worker 会解析目标主机并拒绝私人、回环、链路本地等非公网地址,降低 SSRF 和 DNS 重绑定风险。地理定位服务只接收服务器公网 IP,不接收 SSH 用户名、密码、私钥或命令。
|
||||
|
||||
## 会话边界
|
||||
|
||||
每个 SSH 会话由 Durable Object 隔离。浏览器使用一次性票据建立主 WebSocket,辅助通道使用附着令牌,并校验同源请求。
|
||||
|
||||
## 操作建议
|
||||
|
||||
- Access 策略只允许明确管理员身份。
|
||||
- SSH 使用低权限专用账号或密钥。
|
||||
- 定期撤销不再使用的 Cloudflare Token。
|
||||
- 不在日志、截图、Issue 中泄露凭据或 Access 信息。
|
||||
- 真实操作前核对服务器指纹。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 文件与进程
|
||||
|
||||
终端旁的辅助面板通过独立 SSH 通道运行。它们与主终端共享当前受认证会话,但拥有各自的附着令牌和消息边界。
|
||||
|
||||
## SFTP 文件管理
|
||||
|
||||
当前实现基于 SFTP v3,支持:
|
||||
|
||||
- 浏览远端目录。
|
||||
- 上传与下载单个文件。
|
||||
- 新建目录。
|
||||
- 重命名文件或目录。
|
||||
- 删除文件。
|
||||
- 删除空目录。
|
||||
|
||||
单文件上传与下载上限为 64 MiB。大文件、目录递归传输或需要断点续传时,建议在终端中使用服务器已有工具。
|
||||
|
||||
::: warning 先用测试目录验收
|
||||
首次部署后,在专门测试目录中完成上传、下载、重命名与删除。不要直接使用 `/etc`、应用数据目录或生产发布目录。
|
||||
:::
|
||||
|
||||
## 进程面板
|
||||
|
||||
进程面板通过远端命令收集:
|
||||
|
||||
- CPU 使用情况。
|
||||
- 系统负载。
|
||||
- 内存与 Swap。
|
||||
- 进程列表。
|
||||
|
||||
显示结果取决于远端系统提供的命令和输出格式。系统过于精简或权限受限时,部分指标可能不可用,但不应影响主终端。
|
||||
|
||||
## 系统识别
|
||||
|
||||
EdgeSSH 会读取远端系统信息,识别常见 Linux 发行版、版本与架构。探测只用于界面展示,不会安装软件或修改系统文件。
|
||||
|
||||
## 辅助面板故障
|
||||
|
||||
遇到面板无法加载时:
|
||||
|
||||
1. 先确认主终端仍可执行命令。
|
||||
2. 检查 SSH 用户是否有权读取目标目录或进程信息。
|
||||
3. 查看浏览器中的用户可读错误,不要上传包含凭据的完整网络记录。
|
||||
4. 重新进入工作台,排除辅助通道的临时断开。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 管理主机
|
||||
|
||||
通过 Cloudflare Access 登录后,首页会显示当前身份拥有的主机。不同 Access `sub` 的数据彼此隔离,同一身份最多保存 200 台主机。
|
||||
|
||||
## 新建主机
|
||||
|
||||
选择新增主机,填写:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| 名称 | 用于列表识别,例如“东京测试机” |
|
||||
| 地址 | 公网域名或公网 IP,不带协议 |
|
||||
| 端口 | SSH 端口,默认通常为 `22` |
|
||||
| 用户名 | 目标服务器的 SSH 用户 |
|
||||
| 分组 | 可选,用于筛选 |
|
||||
| 认证方式 | 密码或未加密 OpenSSH 私钥 |
|
||||
| 终端编码 | UTF-8、GB18030 或 Big5 |
|
||||
|
||||
保存地址时,Worker 会解析公网 IP 并尝试获取城市信息。定位失败不会阻止保存或连接;地球上的点位只表示记录位置,不表示服务器在线。
|
||||
|
||||
## 凭据更新规则
|
||||
|
||||
编辑主机时,凭据输入留空表示保留原值。切换认证方式时,应明确填写新方式所需凭据并保存。
|
||||
|
||||
主机列表接口不会返回密码或私钥。建立连接前,Worker 才会解密当前记录,并为一次性会话票据准备连接信息。
|
||||
|
||||
## 搜索与分组
|
||||
|
||||
使用搜索查找名称、地址或标签,并用分组筛选减少列表范围。地球视图和主机列表都是完整入口:点选地球标记或列表项都可以进入工作台。
|
||||
|
||||
## 刷新位置
|
||||
|
||||
服务器迁移或 DNS 指向变化后,可以从主机编辑界面刷新定位。刷新会重新解析公网地址并更新加密资料,不会检测服务器是否在线。
|
||||
|
||||
## 删除主机
|
||||
|
||||
删除操作只移除当前身份的这条加密记录,不会关闭服务器、删除服务器文件或修改 SSH 配置。若对应工作台仍在使用,先退出会话再删除记录。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 终端与主机指纹
|
||||
|
||||
EdgeSSH 在 Worker 内完成 SSH 2.0 握手、密钥交换、认证和通道管理。浏览器通过 WebSocket 与当前 Durable Object 会话通信。
|
||||
|
||||
## 首次连接
|
||||
|
||||
选择主机后,EdgeSSH 会建立 SSH 传输并展示服务器指纹。首次连接必须从可信渠道核对 SHA-256 指纹:
|
||||
|
||||
```bash
|
||||
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub -E sha256
|
||||
```
|
||||
|
||||
命令需要在目标服务器或可信控制台中运行。不同密钥类型可能使用不同文件名。
|
||||
|
||||
确认指纹后,EdgeSSH 才会继续发送 SSH 凭据。已保存的指纹会参与后续连接检查。
|
||||
|
||||
## 指纹发生变化
|
||||
|
||||
不要直接接受变化。常见原因包括:
|
||||
|
||||
- 服务器重装或重新生成 Host Key。
|
||||
- 域名解析到了另一台主机。
|
||||
- 负载均衡后端没有统一主机密钥。
|
||||
- 连接遭到中间人拦截。
|
||||
|
||||
先通过云厂商控制台或服务器本地核实,再在主机记录中更新指纹。
|
||||
|
||||
## 终端操作
|
||||
|
||||
工作台支持:
|
||||
|
||||
- 交互式 Shell 与 PTY。
|
||||
- 浏览器尺寸变化时同步终端行列。
|
||||
- 全屏模式。
|
||||
- 当前会话日志。
|
||||
- UTF-8、GB18030 与 Big5 解码。
|
||||
|
||||
如果中文乱码,先确认服务器 Locale,再切换 EdgeSSH 的终端编码。编码设置只影响浏览器怎样解释字节,不会修改远端系统 Locale。
|
||||
|
||||
## 认证方式
|
||||
|
||||
支持密码、单密码提示的 keyboard-interactive,以及未加密 OpenSSH 私钥。更复杂的 MFA、多轮键盘交互、SSH Agent 和带 passphrase 私钥暂不支持。
|
||||
|
||||
## 退出会话
|
||||
|
||||
从工作台返回主机总览会断开当前 SSH 会话。不要把浏览器标签页长期当作后台任务运行器;需要持续执行的命令应在服务器端使用 `tmux`、`screen` 或 systemd 等工具托管。
|
||||
@@ -0,0 +1,3 @@
|
||||
name = "edgessh-docs"
|
||||
pages_build_output_dir = ".vitepress/dist"
|
||||
compatibility_date = "2026-09-20"
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script type="module" src="/src/language-bootstrap.ts"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#080b10" />
|
||||
<meta name="theme-color" content="#fafbfd" />
|
||||
<meta
|
||||
name="description"
|
||||
content="安全的 Cloudflare Workers 原生 WebSSH 终端"
|
||||
@@ -33,9 +33,9 @@
|
||||
<a
|
||||
class="brand"
|
||||
href="/"
|
||||
aria-label="Workers WebSSH 首页"
|
||||
data-i18n-aria-label-zh="Workers WebSSH 首页"
|
||||
data-i18n-aria-label-en="Workers WebSSH home"
|
||||
aria-label="EdgeSSH 首页"
|
||||
data-i18n-aria-label-zh="EdgeSSH 首页"
|
||||
data-i18n-aria-label-en="EdgeSSH home"
|
||||
>
|
||||
<span class="brand-mark" aria-hidden="true">>_</span>
|
||||
<span><strong>Edge</strong>SSH</span>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { resetTerminalForConnection } from './terminal-session';
|
||||
import { WebSocketReconnectManager } from './ws-reconnect';
|
||||
import type { ReconnectLogEntry } from './ws-reconnect';
|
||||
import './style.css';
|
||||
import './workspace-theme.css';
|
||||
|
||||
type AuthMethod = 'password' | 'publickey';
|
||||
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnecting' | 'error';
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/* Hallmark · macrostructure: Workbench(保留) · theme: EdgeSSH white / orange
|
||||
* pre-emit critique: P5 H4 E4 S5 R5 V4
|
||||
* 独立适配上游工作台,不改其 DOM、布局状态类或业务代码,降低后续合并成本。
|
||||
* 总览的 --home-* 是品牌来源;终端输出继续使用上游深色 ANSI 调色板。
|
||||
*/
|
||||
|
||||
:root {
|
||||
--sans: var(--home-font);
|
||||
--mono: var(--home-mono);
|
||||
--workspace-dock-height: 40px;
|
||||
--workspace-on-accent: #fff;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--bg: var(--home-bg);
|
||||
--panel: var(--home-surface);
|
||||
--surface: var(--home-surface);
|
||||
--surface-2: var(--home-bg);
|
||||
--surface-3: var(--home-line);
|
||||
--border: var(--home-line);
|
||||
--border-soft: var(--home-line);
|
||||
--text: var(--home-ink);
|
||||
--muted: var(--home-muted);
|
||||
--muted-2: var(--home-muted);
|
||||
/* 小号橙色文字用总览的深橙色;实心按钮仍保持原品牌橙色。 */
|
||||
--accent: var(--home-accent-hover);
|
||||
--accent-strong: var(--home-accent-hover);
|
||||
--accent-dim: var(--home-accent-soft);
|
||||
--workspace-action: var(--home-accent);
|
||||
--workspace-success: var(--home-success);
|
||||
--danger: var(--home-danger);
|
||||
--shadow: 0 8px 28px var(--home-shadow);
|
||||
--terminal: #080d12;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--bg: #13161c;
|
||||
--panel: #1a1e26;
|
||||
--surface: #1e232c;
|
||||
--surface-2: #252b35;
|
||||
--surface-3: #303846;
|
||||
--border: #394150;
|
||||
--border-soft: #2c3340;
|
||||
--text: #eef0f4;
|
||||
--muted: #b0b8c6;
|
||||
--muted-2: #99a4b5;
|
||||
--accent: #ff9a70;
|
||||
--accent-strong: #ffb393;
|
||||
--accent-dim: #ff9a701a;
|
||||
--workspace-action: var(--accent);
|
||||
--workspace-on-accent: #29150d;
|
||||
--workspace-success: #69e6b4;
|
||||
--terminal: #080d12;
|
||||
}
|
||||
|
||||
/* 所有组件适配限定在 #app,避免反向改变用户自己的总览与主机编辑弹窗。 */
|
||||
#app .topbar {
|
||||
background: var(--panel);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#app .brand {
|
||||
gap: 12px;
|
||||
font-family: var(--home-display);
|
||||
font-size: 22px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -.7px;
|
||||
}
|
||||
|
||||
#app .brand > span:last-child { color: var(--workspace-action); }
|
||||
#app .brand strong { color: var(--text); font-weight: inherit; }
|
||||
#app .brand-mark {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--workspace-action);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
#app .home-back {
|
||||
width: auto;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#app .workspace { background: var(--bg); }
|
||||
#app .terminal-card {
|
||||
grid-template-rows: minmax(0, 1fr) minmax(var(--workspace-dock-height), 0fr);
|
||||
box-shadow: none;
|
||||
}
|
||||
#app .terminal-card.workspace-panel-open {
|
||||
grid-template-rows: minmax(0, 1.618fr) minmax(var(--workspace-dock-height), 1fr);
|
||||
}
|
||||
#app .workspace-dock { grid-template-rows: var(--workspace-dock-height) minmax(0, 1fr); }
|
||||
|
||||
/* 工具栏属于应用外壳,不再与终端输出一起固定为绿黑色。 */
|
||||
#app .terminal-toolbar {
|
||||
border-color: var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
}
|
||||
#app .session-target strong,
|
||||
#app .resource-metric-line > strong { color: var(--text); }
|
||||
#app .session-target span,
|
||||
#app .session-metrics :is(span, strong),
|
||||
#app .resource-metric,
|
||||
#app .resource-metric-line > span,
|
||||
#app .resource-metric-line > .resource-detail,
|
||||
#app .resource-network #resource-network-iface { color: var(--muted); }
|
||||
#app .resource-metric-line > .resource-percentage {
|
||||
color: color-mix(in srgb, var(--usage-color, var(--text)) 70%, var(--text));
|
||||
}
|
||||
#app .session-metrics > div,
|
||||
#app .resource-metric + .resource-metric { border-color: var(--border); }
|
||||
#app .resource-metric progress::-webkit-progress-bar { background: var(--surface-3); }
|
||||
#app .resource-sparkline { background: var(--surface-2); }
|
||||
#app .resource-network .resource-network-select,
|
||||
#app .resource-network .resource-network-select option {
|
||||
color: var(--text);
|
||||
background-color: var(--surface-2);
|
||||
border-color: var(--border);
|
||||
}
|
||||
#app .resource-network #resource-network-rate-up { color: var(--danger); }
|
||||
#app .resource-network #resource-network-rate-down { color: var(--workspace-success); }
|
||||
#app .terminal-actions .toolbar-button {
|
||||
min-height: 30px;
|
||||
border-color: var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
#app .terminal-actions .toolbar-button:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 成功仍用绿色、危险仍用红色,品牌色不能替代状态语义。 */
|
||||
#app .live-orb.connected {
|
||||
background: var(--workspace-success);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--workspace-success) 14%, transparent);
|
||||
}
|
||||
#app .process-kill-result[data-state='success'] {
|
||||
border-color: var(--workspace-success);
|
||||
background: color-mix(in srgb, var(--workspace-success) 10%, var(--surface));
|
||||
color: var(--workspace-success);
|
||||
}
|
||||
|
||||
#app .terminal-stage { background: var(--terminal); }
|
||||
#app .terminal-empty { background: var(--surface); color: var(--text); }
|
||||
#app .terminal-empty p { color: var(--muted); }
|
||||
#app .empty-glyph,
|
||||
#app .terminal-empty .small-button {
|
||||
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
#app .terminal-empty .small-button {
|
||||
min-height: 40px;
|
||||
padding-inline: 16px;
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
}
|
||||
#app .terminal-empty .small-button:hover { border-color: var(--accent); }
|
||||
#app .primary-button:not(.is-danger) {
|
||||
background: var(--workspace-action);
|
||||
color: var(--workspace-on-accent);
|
||||
}
|
||||
#app .primary-button:not(.is-danger):hover:not(:disabled) {
|
||||
background: var(--accent-strong);
|
||||
transform: none;
|
||||
}
|
||||
#app .host-key-dialog.changed .primary-button {
|
||||
background: var(--danger);
|
||||
color: var(--home-surface);
|
||||
}
|
||||
#app .event-bar,
|
||||
#app .dock-tab { font-family: var(--sans); }
|
||||
#app .dock-tab { font-size: 12px; }
|
||||
#app .event-line time { color: var(--muted); }
|
||||
#app .event-line strong { color: var(--accent); }
|
||||
#app .event-line.error strong { color: var(--danger); }
|
||||
#app .field > span:first-child,
|
||||
#app .field > label:first-child,
|
||||
#app .auth-fieldset legend,
|
||||
#app .segmented-control span,
|
||||
#app .primary-button,
|
||||
#app .secondary-button { font-size: 12px; }
|
||||
#app .field input,
|
||||
#app .field select { height: 42px; }
|
||||
#app .privacy-note { font-size: 11px; line-height: 1.65; }
|
||||
#app :is(.host-key-dialog, .process-kill-dialog) { box-shadow: var(--shadow); }
|
||||
#app :is(.host-key-dialog, .process-kill-dialog)::backdrop { background: var(--home-overlay); }
|
||||
#app .file-tree-row:focus-visible,
|
||||
#app .process-copyable:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
#app .terminal-card.workspace-panel-open {
|
||||
grid-template-rows: minmax(0, 1fr) minmax(var(--workspace-dock-height), 1fr);
|
||||
}
|
||||
/* 资源独占第二行,让窄屏不必在连接状态和操作按钮之间挤压指标。 */
|
||||
#app .terminal-pane { grid-template-rows: 88px minmax(0, 1fr); }
|
||||
#app .terminal-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-rows: 44px 44px;
|
||||
gap: 0 8px;
|
||||
}
|
||||
#app .session-target { grid-column: 1; grid-row: 1; }
|
||||
#app .terminal-actions { grid-column: 2; grid-row: 1; }
|
||||
#app .resource-metrics {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--border);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
#app .resource-metric { flex: 1 0 auto; }
|
||||
#app .resource-metric-line { justify-content: flex-start; }
|
||||
#app .resource-network .resource-network-select { color: transparent; background: transparent; }
|
||||
#app .topbar-actions, #app .brand-group { gap: 6px; }
|
||||
#app .dock-tab { padding-inline: 8px; font-size: 11px; }
|
||||
#app .field :is(input, select, textarea) { font-size: 16px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
/* 窄屏优先保留返回、语言与主题操作,仓库入口在桌面仍保持原位。 */
|
||||
#app .topbar-actions > a[href] { display: none; }
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
:root { --workspace-dock-height: 44px; }
|
||||
#app .topbar .icon-button { min-width: 44px; min-height: 44px; }
|
||||
#app .terminal-actions .toolbar-button { min-width: 44px; min-height: 40px; }
|
||||
#app .file-action { min-width: 44px; min-height: 44px; }
|
||||
}
|
||||
Generated
+2004
-4
File diff suppressed because it is too large.
Load diff
+11
-2
@@ -8,10 +8,18 @@
|
||||
"dev": "wrangler dev",
|
||||
"dev:web": "vite --config vite.config.ts",
|
||||
"build:web": "vite build --config vite.config.ts",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs",
|
||||
"typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.frontend.json --noEmit",
|
||||
"test": "node --test --experimental-strip-types tests/*.test.ts",
|
||||
"check": "npm run typecheck && npm run test && wrangler deploy --dry-run --outdir .wrangler/build",
|
||||
"deploy": "wrangler deploy"
|
||||
"check": "npm run typecheck && npm run test && npm run docs:build && wrangler deploy --dry-run --outdir .wrangler/build",
|
||||
"prepare:docs": "node scripts/prepare-pages-project.mjs",
|
||||
"deploy:docs": "npm run docs:build && node scripts/prepare-pages-project.mjs --deploy",
|
||||
"prepare:deploy": "node scripts/prepare-deployment.mjs",
|
||||
"deploy:secrets": "node scripts/sync-worker-secrets.mjs",
|
||||
"deploy:worker": "wrangler d1 migrations apply DB --remote --config wrangler.deploy.toml && wrangler deploy --config wrangler.deploy.toml",
|
||||
"deploy": "npm run prepare:deploy && npm run deploy:secrets && npm run deploy:worker"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
@@ -26,6 +34,7 @@
|
||||
"flag-icons": "^7.5.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.0.6",
|
||||
"vitepress": "^1.6.4",
|
||||
"wrangler": "^4.134.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<script type="module" src="/@vite/client"></script>
|
||||
|
||||
<meta charset="UTF-8" />
|
||||
<script type="module" src="/src/language-bootstrap.ts"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#fafbfd" />
|
||||
<meta
|
||||
name="description"
|
||||
content="安全的 Cloudflare Workers 原生 WebSSH 终端"
|
||||
data-i18n-content-zh="安全的 Cloudflare Workers 原生 WebSSH 终端"
|
||||
data-i18n-content-en="A secure, native Cloudflare Workers WebSSH terminal"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>EdgeSSH · Private workspace</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" hidden>
|
||||
<header class="topbar">
|
||||
<div class="brand-group">
|
||||
<button
|
||||
id="panel-toggle"
|
||||
class="icon-button"
|
||||
type="button"
|
||||
aria-label="打开连接面板"
|
||||
data-i18n-aria-label-zh="打开连接面板"
|
||||
data-i18n-aria-label-en="Open connection panel"
|
||||
aria-controls="connection-panel"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||
</button>
|
||||
<a
|
||||
class="brand"
|
||||
href="/"
|
||||
aria-label="EdgeSSH 首页"
|
||||
data-i18n-aria-label-zh="EdgeSSH 首页"
|
||||
data-i18n-aria-label-en="EdgeSSH home"
|
||||
>
|
||||
<span class="brand-mark" aria-hidden="true">>_</span>
|
||||
<span><strong>Edge</strong>SSH</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<button id="language-toggle" class="icon-button" type="button" aria-label="切换到英文" title="切换到英文" data-language="zh-CN">
|
||||
<span class="language-icon" aria-hidden="true"><span>中</span><span>E</span></span>
|
||||
</button>
|
||||
<button
|
||||
id="theme-toggle"
|
||||
class="icon-button"
|
||||
type="button"
|
||||
aria-label="切换颜色主题"
|
||||
data-i18n-aria-label-zh="切换颜色主题"
|
||||
data-i18n-aria-label-en="Toggle color theme"
|
||||
>
|
||||
<svg class="sun-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.65 17.65l1.42 1.42M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.65 6.35l1.42-1.42" /></svg>
|
||||
</button>
|
||||
<a
|
||||
class="icon-button"
|
||||
href="https://github.com/cmliu/CF-Workers-WebSSH"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="打开源代码仓库"
|
||||
data-i18n-aria-label-zh="打开源代码仓库"
|
||||
data-i18n-aria-label-en="Open source repository"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.87c-2.78.6-3.37-1.18-3.37-1.18-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.35 1.09 2.92.83.09-.65.35-1.09.64-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.64 0 0 .84-.27 2.75 1.02A9.56 9.56 0 0 1 12 6.82c.85 0 1.71.11 2.51.34 1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.39.1 2.64.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.86v2.76c0 .27.18.58.69.48A10 10 0 0 0 12 2Z" /></svg>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="app-shell" class="app-shell">
|
||||
<button
|
||||
id="panel-scrim"
|
||||
class="panel-scrim"
|
||||
type="button"
|
||||
aria-label="关闭连接面板"
|
||||
data-i18n-aria-label-zh="关闭连接面板"
|
||||
data-i18n-aria-label-en="Close connection panel"
|
||||
hidden
|
||||
></button>
|
||||
<aside
|
||||
id="connection-panel"
|
||||
class="connection-panel"
|
||||
aria-label="连接管理"
|
||||
data-i18n-aria-label-zh="连接管理"
|
||||
data-i18n-aria-label-en="Connection management"
|
||||
>
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow" data-i18n-zh="连接管理" data-i18n-en="CONNECTIONS">连接管理</p>
|
||||
<h1 data-i18n-zh="SSH 工作台" data-i18n-en="SSH workspace">SSH 工作台</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="profiles-section" aria-labelledby="profiles-title">
|
||||
<div class="section-title-row">
|
||||
<h2 id="profiles-title" data-i18n-zh="云端主机" data-i18n-en="Cloud hosts">云端主机</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 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 host-grid">
|
||||
<label class="field">
|
||||
<span data-i18n-zh="主机" data-i18n-en="Host">主机</span>
|
||||
<input id="host" name="host" type="text" maxlength="253" placeholder="ssh.example.com" required spellcheck="false" />
|
||||
</label>
|
||||
<label class="field port-field">
|
||||
<span data-i18n-zh="端口" data-i18n-en="Port">端口</span>
|
||||
<input id="port" name="port" type="number" value="22" min="1" max="65535" required inputmode="numeric" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid single">
|
||||
<label class="field">
|
||||
<span data-i18n-zh="用户名" data-i18n-en="Username">用户名</span>
|
||||
<input id="username" name="username" type="text" value="root" maxlength="128" required autocomplete="username" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset class="auth-fieldset">
|
||||
<legend data-i18n-zh="身份认证" data-i18n-en="Authentication">身份认证</legend>
|
||||
<div
|
||||
class="segmented-control"
|
||||
role="radiogroup"
|
||||
aria-label="身份认证方式"
|
||||
data-i18n-aria-label-zh="身份认证方式"
|
||||
data-i18n-aria-label-en="Authentication method"
|
||||
>
|
||||
<label><input type="radio" name="authMethod" value="password" checked /><span data-i18n-zh="密码" data-i18n-en="Password">密码</span></label>
|
||||
<label><input type="radio" name="authMethod" value="publickey" /><span data-i18n-zh="SSH 密钥" data-i18n-en="SSH key">SSH 密钥</span></label>
|
||||
</div>
|
||||
|
||||
<div id="password-field" class="field secret-field">
|
||||
<label for="password">
|
||||
<span data-i18n-zh="密码" data-i18n-en="Password">密码</span>
|
||||
<small data-i18n-zh="连接成功后使用 AES-GCM 加密保存" data-i18n-en="Encrypted with AES-GCM after a successful connection">连接成功后使用 AES-GCM 加密保存</small>
|
||||
</label>
|
||||
<span class="input-action-wrap">
|
||||
<input id="password" name="password" type="password" maxlength="4096" autocomplete="current-password" />
|
||||
<button id="reveal-password" class="input-action" type="button" aria-label="显示密码" aria-pressed="false">
|
||||
<svg class="reveal-icon reveal-icon-eye" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<svg class="reveal-icon reveal-icon-eye-off" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
|
||||
<path d="M6.61 6.61A13.53 13.53 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
|
||||
<line x1="2" x2="22" y1="2" y2="22" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="key-field" class="key-field" hidden>
|
||||
<label class="field">
|
||||
<span>
|
||||
<span data-i18n-zh="私钥" data-i18n-en="Private key">私钥</span>
|
||||
<small data-i18n-zh="未加密 OpenSSH" data-i18n-en="unencrypted OpenSSH">未加密 OpenSSH</small>
|
||||
</span>
|
||||
<textarea id="private-key" name="privateKey" rows="5" maxlength="65536" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
|
||||
</label>
|
||||
<label class="file-picker">
|
||||
<input id="key-file" type="file" accept=".pem,.key,.openssh,text/plain" />
|
||||
<span data-i18n-zh="选择密钥文件" data-i18n-en="Choose key file">选择密钥文件</span>
|
||||
<small id="key-file-name">未选择文件</small>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<details class="advanced-options">
|
||||
<summary data-i18n-zh="高级选项" data-i18n-en="Advanced options">高级选项</summary>
|
||||
<div class="advanced-body">
|
||||
<label class="field">
|
||||
<span data-i18n-zh="初始命令" data-i18n-en="Initial command">初始命令</span>
|
||||
<input id="initial-command" name="initialCommand" type="text" maxlength="4096" placeholder="tmux attach || tmux" spellcheck="false" />
|
||||
</label>
|
||||
<div class="field-grid two">
|
||||
<label class="field">
|
||||
<span data-i18n-zh="终端类型" data-i18n-en="Terminal">终端类型</span>
|
||||
<select id="term-type" name="termType">
|
||||
<option value="xterm-256color">xterm-256color</option>
|
||||
<option value="xterm">xterm</option>
|
||||
<option value="screen-256color">screen-256color</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span data-i18n-zh="字符编码" data-i18n-en="Encoding">字符编码</span>
|
||||
<select id="encoding" name="encoding">
|
||||
<option value="utf-8">UTF-8</option>
|
||||
<option value="gb18030">GB18030</option>
|
||||
<option value="big5">Big5</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>
|
||||
<span data-i18n-zh="预期主机指纹" data-i18n-en="Expected host fingerprint">预期主机指纹</span>
|
||||
<small>SHA-256</small>
|
||||
</span>
|
||||
<input
|
||||
id="fingerprint"
|
||||
name="fingerprint"
|
||||
type="text"
|
||||
maxlength="128"
|
||||
placeholder="首次使用后自动保存"
|
||||
data-i18n-placeholder-zh="首次使用后自动保存"
|
||||
data-i18n-placeholder-en="Saved after first use"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="form-error" class="form-error" role="alert" hidden></div>
|
||||
<div class="form-actions">
|
||||
<button id="connect-button" class="primary-button" type="submit">
|
||||
<span class="button-icon" aria-hidden="true">>_</span>
|
||||
<span>连接</span>
|
||||
</button>
|
||||
<button
|
||||
id="share-link"
|
||||
class="icon-button bordered"
|
||||
type="button"
|
||||
aria-label="复制连接链接(不含凭据)"
|
||||
data-i18n-aria-label-zh="复制连接链接(不含凭据)"
|
||||
data-i18n-aria-label-en="Copy connection link (without credentials)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p
|
||||
class="privacy-note"
|
||||
data-i18n-zh="成功连接后,主机资料、密码或私钥会使用 Worker 密钥加密并保存至 D1。浏览器不持久化凭据。"
|
||||
data-i18n-en="Successful connections save host details and credentials encrypted in D1. Credentials are not persisted in the browser."
|
||||
>成功连接后,主机资料、密码或私钥会使用 Worker 密钥加密并保存至 D1。浏览器不持久化凭据。</p>
|
||||
</aside>
|
||||
|
||||
<main class="workspace">
|
||||
<section id="terminal-card" class="terminal-card">
|
||||
<div class="terminal-pane">
|
||||
<div class="terminal-toolbar">
|
||||
<div
|
||||
class="session-target"
|
||||
aria-label="实时连接状态"
|
||||
data-i18n-aria-label-zh="实时连接状态"
|
||||
data-i18n-aria-label-en="Live connection status"
|
||||
>
|
||||
<span class="live-orb idle" id="live-orb" role="status" aria-live="polite" aria-atomic="true" title="离线">
|
||||
<span id="live-orb-label" class="sr-only">离线</span>
|
||||
</span>
|
||||
<div>
|
||||
<strong id="session-title">无活动会话</strong>
|
||||
<span id="session-subtitle">选择目标并连接</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-metrics" aria-label="系统资源" data-i18n-aria-label-zh="系统资源" data-i18n-aria-label-en="System resources">
|
||||
<div class="resource-metric resource-cpu">
|
||||
<div class="resource-metric-line">
|
||||
<span data-i18n-zh="CPU" data-i18n-en="CPU">CPU</span>
|
||||
<strong id="resource-cpu-value" class="resource-percentage">--</strong>
|
||||
<strong id="resource-cpu-load" class="resource-detail">-- / -- / --</strong>
|
||||
</div>
|
||||
<progress id="resource-cpu-progress" max="100" value="0" aria-label="CPU 使用率" data-i18n-aria-label-zh="CPU 使用率" data-i18n-aria-label-en="CPU usage"></progress>
|
||||
</div>
|
||||
<div class="resource-metric resource-memory">
|
||||
<div class="resource-metric-line">
|
||||
<span data-i18n-zh="内存" data-i18n-en="MEM">内存</span>
|
||||
<strong id="resource-memory-value" class="resource-percentage">--</strong>
|
||||
<strong id="resource-memory-amount" class="resource-detail">--/--</strong>
|
||||
</div>
|
||||
<progress id="resource-memory-progress" max="100" value="0" aria-label="内存使用率" data-i18n-aria-label-zh="内存使用率" data-i18n-aria-label-en="Memory usage"></progress>
|
||||
</div>
|
||||
<div class="resource-metric resource-swap">
|
||||
<div class="resource-metric-line">
|
||||
<span data-i18n-zh="Swap" data-i18n-en="SWAP">Swap</span>
|
||||
<strong id="resource-swap-value" class="resource-percentage">--</strong>
|
||||
<strong id="resource-swap-amount" class="resource-detail">--/--</strong>
|
||||
</div>
|
||||
<progress id="resource-swap-progress" max="100" value="0" aria-label="Swap 使用率" data-i18n-aria-label-zh="Swap 使用率" data-i18n-aria-label-en="Swap usage"></progress>
|
||||
</div>
|
||||
<div class="resource-metric resource-network" id="resource-network" hidden>
|
||||
<div class="resource-metric-line">
|
||||
<strong id="resource-network-iface" class="resource-detail">-</strong>
|
||||
<select id="resource-network-select" class="resource-network-select" hidden aria-label="网卡选择" data-i18n-aria-label-zh="网卡选择" data-i18n-aria-label-en="Network interface"></select>
|
||||
<div class="resource-network-rates">
|
||||
<strong id="resource-network-rate-up">↑ 0 B/s</strong>
|
||||
<strong id="resource-network-rate-down">↓ 0 B/s</strong>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="resource-network-sparkline" class="resource-sparkline" width="184" height="12" aria-label="网络速率" data-i18n-aria-label-zh="网络速率" data-i18n-aria-label-en="Network rate"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-metrics">
|
||||
<div><span data-i18n-zh="时长" data-i18n-en="UPTIME">时长</span><strong id="metric-uptime">00:00</strong></div>
|
||||
<div><span data-i18n-zh="主机密钥" data-i18n-en="HOST KEY">主机密钥</span><strong id="metric-host-key">--</strong></div>
|
||||
</div>
|
||||
<div class="terminal-actions">
|
||||
<button id="clear-terminal" class="toolbar-button" type="button" aria-label="清屏" data-i18n-aria-label-zh="清屏" data-i18n-aria-label-en="Clear">
|
||||
<svg class="toolbar-button-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</svg>
|
||||
<span class="button-label" data-i18n-zh="清屏" data-i18n-en="Clear">清屏</span>
|
||||
</button>
|
||||
<button id="fullscreen-terminal" class="toolbar-button" type="button" aria-label="全屏" data-i18n-aria-label-zh="全屏" data-i18n-aria-label-en="Fullscreen">
|
||||
<svg class="toolbar-button-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
<span class="button-label" data-i18n-zh="全屏" data-i18n-en="Fullscreen">全屏</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal-stage" class="terminal-stage">
|
||||
<div
|
||||
id="terminal"
|
||||
role="application"
|
||||
aria-label="SSH 终端"
|
||||
data-i18n-aria-label-zh="SSH 终端"
|
||||
data-i18n-aria-label-en="SSH terminal"
|
||||
></div>
|
||||
<div id="terminal-empty" class="terminal-empty">
|
||||
<div class="empty-glyph" aria-hidden="true">>_</div>
|
||||
<h2 data-i18n-zh="终端已就绪" data-i18n-en="Your terminal is ready">终端已就绪</h2>
|
||||
<p
|
||||
data-i18n-zh="从连接面板建立 SSH 连接,开始加密的边缘会话。"
|
||||
data-i18n-en="Open an SSH connection from the panel to start an encrypted edge session."
|
||||
>从连接面板建立 SSH 连接,开始加密的边缘会话。</p>
|
||||
<button id="empty-connect" class="small-button" type="button" data-i18n-zh="配置连接" data-i18n-en="Configure connection">配置连接</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile shortcut key toolbar (touch devices only) -->
|
||||
<div id="mobile-toolbar" class="mobile-toolbar" hidden>
|
||||
<button
|
||||
id="mobile-toolbar-toggle"
|
||||
class="mobile-toolbar-toggle"
|
||||
type="button"
|
||||
aria-label="打开快捷键工具栏"
|
||||
data-i18n-aria-label-zh="打开快捷键工具栏"
|
||||
data-i18n-aria-label-en="Open shortcut toolbar"
|
||||
aria-expanded="false"
|
||||
title="快捷键"
|
||||
data-i18n-title-zh="快捷键"
|
||||
data-i18n-title-en="Shortcuts"
|
||||
>
|
||||
<svg class="mobile-toolbar-toggle-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M6 8h2M10 8h2M14 8h2M6 12h6M6 16h4M16 12h2M16 16h2" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="mobile-toolbar-panel" class="mobile-toolbar-panel" hidden>
|
||||
<div class="mobile-toolbar-row">
|
||||
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-c" aria-label="Ctrl+C" data-i18n-aria-label-zh="Ctrl+C 中断" data-i18n-aria-label-en="Ctrl+C Interrupt">Ctrl + C</button>
|
||||
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-d" aria-label="Ctrl+D" data-i18n-aria-label-zh="Ctrl+D 退出" data-i18n-aria-label-en="Ctrl+D Exit">Ctrl + D</button>
|
||||
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="esc" aria-label="Esc" data-i18n-aria-label-zh="Esc 退出模式" data-i18n-aria-label-en="Esc Exit mode">Esc</button>
|
||||
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="tab" aria-label="Tab" data-i18n-aria-label-zh="Tab 补全" data-i18n-aria-label-en="Tab Complete">Tab</button>
|
||||
<button class="mobile-key-btn mobile-key-enter" type="button" data-key="enter" aria-label="Enter" data-i18n-aria-label-zh="Enter 提交命令" data-i18n-aria-label-en="Enter Submit">Enter</button>
|
||||
<span class="mobile-toolbar-sep" aria-hidden="true"></span>
|
||||
<div class="mobile-arrow-group">
|
||||
<div class="mobile-arrow-row">
|
||||
<button class="mobile-key-btn mobile-key-arrow" type="button" data-key="up" aria-label="上方向键" data-i18n-aria-label-zh="上方向键" data-i18n-aria-label-en="Up arrow">↑</button>
|
||||
</div>
|
||||
<div class="mobile-arrow-row">
|
||||
<button class="mobile-key-btn mobile-key-arrow" type="button" data-key="left" aria-label="左方向键" data-i18n-aria-label-zh="左方向键" data-i18n-aria-label-en="Left arrow">←</button>
|
||||
<button class="mobile-key-btn mobile-key-arrow" type="button" data-key="down" aria-label="下方向键" data-i18n-aria-label-zh="下方向键" data-i18n-aria-label-en="Down arrow">↓</button>
|
||||
<button class="mobile-key-btn mobile-key-arrow" type="button" data-key="right" aria-label="右方向键" data-i18n-aria-label-zh="右方向键" data-i18n-aria-label-en="Right arrow">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-toolbar-row">
|
||||
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-z" aria-label="Ctrl+Z" data-i18n-aria-label-zh="Ctrl+Z 挂起" data-i18n-aria-label-en="Ctrl+Z Suspend">Ctrl + Z</button>
|
||||
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-l" aria-label="Ctrl+L" data-i18n-aria-label-zh="Ctrl+L 清屏" data-i18n-aria-label-en="Ctrl+L Clear">Ctrl + L</button>
|
||||
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-r" aria-label="Ctrl+R" data-i18n-aria-label-zh="Ctrl+R 反向历史搜索" data-i18n-aria-label-en="Ctrl+R Reverse history search">Ctrl + R</button>
|
||||
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-u" aria-label="Ctrl+U" data-i18n-aria-label-zh="Ctrl+U 删行" data-i18n-aria-label-en="Ctrl+U Kill line">Ctrl + U</button>
|
||||
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-w" aria-label="Ctrl+W" data-i18n-aria-label-zh="Ctrl+W 删词" data-i18n-aria-label-en="Ctrl+W Kill word">Ctrl + W</button>
|
||||
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-k" aria-label="Ctrl+K" data-i18n-aria-label-zh="Ctrl+K 删至行尾" data-i18n-aria-label-en="Ctrl+K Kill to end">Ctrl + K</button>
|
||||
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-a" aria-label="Ctrl+A" data-i18n-aria-label-zh="Ctrl+A 行首" data-i18n-aria-label-en="Ctrl+A Line start">Ctrl + A</button>
|
||||
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-e" aria-label="Ctrl+E" data-i18n-aria-label-zh="Ctrl+E 行尾" data-i18n-aria-label-en="Ctrl+E Line end">Ctrl + E</button>
|
||||
</div>
|
||||
<div class="mobile-toolbar-row">
|
||||
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="home" aria-label="Home Line start" data-i18n-aria-label-zh="Home 行首" data-i18n-aria-label-en="Home Line start">Home</button>
|
||||
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="end" aria-label="End Line end" data-i18n-aria-label-zh="End 行尾" data-i18n-aria-label-en="End Line end">End</button>
|
||||
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="pgup" aria-label="PgUp" data-i18n-aria-label-zh="PgUp 上翻页" data-i18n-aria-label-en="Page Up">PgUp</button>
|
||||
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="pgdn" aria-label="PgDn" data-i18n-aria-label-zh="PgDn 下翻页" data-i18n-aria-label-en="Page Down">PgDn</button>
|
||||
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="delete" aria-label="Delete" data-i18n-aria-label-zh="Delete 删除光标处" data-i18n-aria-label-en="Delete Forward delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workspace-dock">
|
||||
<div class="event-bar">
|
||||
<span id="event-message">Worker 运行时待命</span>
|
||||
<div class="dock-tabs" role="tablist" aria-label="工作区面板" data-i18n-aria-label-zh="工作区面板" data-i18n-aria-label-en="Workspace panels">
|
||||
<button
|
||||
id="file-manager-tab"
|
||||
class="dock-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="file-manager-panel"
|
||||
aria-expanded="false"
|
||||
tabindex="0"
|
||||
aria-label="文件管理"
|
||||
data-i18n-aria-label-zh="文件管理"
|
||||
data-i18n-aria-label-en="Files"
|
||||
>
|
||||
<svg class="dock-tab-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3.5 7.5h6l2-2h9a1.5 1.5 0 0 1 1.5 1.5v11a1.5 1.5 0 0 1-1.5 1.5h-17A1.5 1.5 0 0 1 2 18V9a1.5 1.5 0 0 1 1.5-1.5Z" /></svg>
|
||||
<span class="button-label" data-i18n-zh="文件管理" data-i18n-en="Files">文件管理</span>
|
||||
</button>
|
||||
<button
|
||||
id="process-manager-tab"
|
||||
class="dock-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="process-manager-panel"
|
||||
aria-expanded="false"
|
||||
tabindex="-1"
|
||||
aria-label="进程管理"
|
||||
data-i18n-aria-label-zh="进程管理"
|
||||
data-i18n-aria-label-en="Processes"
|
||||
>
|
||||
<svg class="dock-tab-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" /><path d="m7 9 2.5 2.5L7 14M12.5 14H17" /></svg>
|
||||
<span class="button-label" data-i18n-zh="进程管理" data-i18n-en="Processes">进程管理</span>
|
||||
</button>
|
||||
<button
|
||||
id="event-toggle"
|
||||
class="dock-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="event-log"
|
||||
aria-expanded="false"
|
||||
tabindex="-1"
|
||||
aria-label="会话日志"
|
||||
data-i18n-aria-label-zh="会话日志"
|
||||
data-i18n-aria-label-en="Session log"
|
||||
>
|
||||
<svg class="dock-tab-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 2.5h9l4 4V21H6a2 2 0 0 1-2-2V4.5a2 2 0 0 1 2-2Z" /><path d="M15 2.5V7h4M8 11h7M8 15h7" /></svg>
|
||||
<span class="button-label" data-i18n-zh="会话日志" data-i18n-en="Session log">会话日志</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="file-manager-panel" class="file-manager" role="tabpanel" aria-labelledby="file-manager-tab" tabindex="0" hidden>
|
||||
<aside id="file-tree" class="file-tree" aria-label="目录树" data-i18n-aria-label-zh="目录树" data-i18n-aria-label-en="Directory tree">
|
||||
<div class="file-tree-header">
|
||||
<span class="file-tree-title" data-i18n-zh="目录" data-i18n-en="Directories">目录</span>
|
||||
<button id="file-tree-collapse" class="file-tree-collapse icon-button" type="button" aria-label="折叠侧栏" data-i18n-aria-label-zh="折叠侧栏" data-i18n-aria-label-en="Collapse sidebar" title="折叠侧栏" data-i18n-title-zh="折叠侧栏" data-i18n-title-en="Collapse sidebar">☰</button>
|
||||
</div>
|
||||
<div id="file-tree-scroller" class="file-tree-scroller"></div>
|
||||
<div class="file-tree-resize" aria-hidden="true"></div>
|
||||
</aside>
|
||||
<div class="file-toolbar">
|
||||
<label class="file-path-field" for="file-manager-path">
|
||||
<span class="sr-only" data-i18n-zh="当前路径" data-i18n-en="Current path">当前路径</span>
|
||||
<input id="file-manager-path" type="text" value="/" autocomplete="off" spellcheck="false" aria-label="当前路径" data-i18n-aria-label-zh="当前路径" data-i18n-aria-label-en="Current path" />
|
||||
</label>
|
||||
<div class="file-actions" aria-label="文件操作" data-i18n-aria-label-zh="文件操作" data-i18n-aria-label-en="File actions">
|
||||
<button id="file-back" class="file-action" type="button" disabled data-i18n-aria-label-zh="返回" data-i18n-aria-label-en="Back" aria-label="返回" title="返回" data-i18n-title-zh="返回" data-i18n-title-en="Back">←</button>
|
||||
<button id="file-up" class="file-action" type="button" disabled data-i18n-aria-label-zh="上一级" data-i18n-aria-label-en="Up one level" aria-label="上一级" title="上一级" data-i18n-title-zh="上一级" data-i18n-title-en="Up one level">↑</button>
|
||||
<button id="file-home" class="file-action" type="button" disabled data-i18n-aria-label-zh="主目录" data-i18n-aria-label-en="Home" aria-label="主目录" title="主目录" data-i18n-title-zh="主目录" data-i18n-title-en="Home">⌂</button>
|
||||
<button id="file-refresh" class="file-action" type="button" disabled data-i18n-aria-label-zh="刷新" data-i18n-aria-label-en="Refresh" aria-label="刷新" title="刷新" data-i18n-title-zh="刷新" data-i18n-title-en="Refresh">↻</button>
|
||||
<span class="file-action-separator" aria-hidden="true"></span>
|
||||
<button id="file-upload" class="file-action file-action-wide" type="button" disabled data-i18n-zh="上传" data-i18n-en="Upload">上传</button>
|
||||
<button id="file-download" class="file-action file-action-wide" type="button" disabled data-i18n-zh="下载" data-i18n-en="Download">下载</button>
|
||||
<button id="file-mkdir" class="file-action file-action-wide" type="button" disabled data-i18n-zh="新建" data-i18n-en="New folder">新建</button>
|
||||
<button id="file-rename" class="file-action file-action-wide" type="button" disabled data-i18n-zh="重命名" data-i18n-en="Rename">重命名</button>
|
||||
<button id="file-delete" class="file-action file-action-wide danger" type="button" disabled data-i18n-zh="删除" data-i18n-en="Delete">删除</button>
|
||||
<input id="file-upload-input" class="sr-only" type="file" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-table-wrap">
|
||||
<table class="file-table" aria-describedby="file-manager-status">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" data-i18n-zh="名称" data-i18n-en="Name">名称</th>
|
||||
<th scope="col" data-column="size" data-i18n-zh="大小" data-i18n-en="Size">大小</th>
|
||||
<th scope="col" data-column="type" data-i18n-zh="类型" data-i18n-en="Type">类型</th>
|
||||
<th scope="col" data-column="mtime" data-i18n-zh="修改时间" data-i18n-en="Modified">修改时间</th>
|
||||
<th scope="col" data-column="permissions" data-i18n-zh="权限" data-i18n-en="Permissions">权限</th>
|
||||
<th scope="col" data-column="owner" data-i18n-zh="用户" data-i18n-en="Owner">用户</th>
|
||||
<th scope="col" data-column="group" data-i18n-zh="组" data-i18n-en="Group">组</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="file-table-body"></tbody>
|
||||
</table>
|
||||
<div id="file-manager-loading" class="file-manager-state" role="status" hidden>
|
||||
<span class="file-spinner" aria-hidden="true"></span>
|
||||
<span data-i18n-zh="正在读取目录…" data-i18n-en="Loading directory…">正在读取目录…</span>
|
||||
</div>
|
||||
<div id="file-manager-empty" class="file-manager-state" hidden data-i18n-zh="此目录为空" data-i18n-en="This folder is empty">此目录为空</div>
|
||||
<div id="file-manager-error" class="file-manager-state error" role="alert" hidden></div>
|
||||
</div>
|
||||
<div class="file-statusbar">
|
||||
<span id="file-manager-status" role="status" aria-live="polite" data-i18n-zh="连接 SSH 后即可管理文件" data-i18n-en="Connect to SSH to manage files">连接 SSH 后即可管理文件</span>
|
||||
<div id="file-manager-progress" class="file-progress" hidden>
|
||||
<span id="file-manager-progress-label"></span>
|
||||
<progress id="file-manager-progress-bar" max="100" value="0" aria-labelledby="file-manager-progress-label"></progress>
|
||||
<button
|
||||
id="file-manager-progress-cancel"
|
||||
class="file-progress-cancel"
|
||||
type="button"
|
||||
data-i18n-zh="取消"
|
||||
data-i18n-en="Cancel"
|
||||
data-i18n-aria-label-zh="取消文件传输"
|
||||
data-i18n-aria-label-en="Cancel file transfer"
|
||||
aria-label="取消文件传输"
|
||||
>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="process-manager-panel" class="process-manager" role="tabpanel" aria-labelledby="process-manager-tab" tabindex="0" hidden>
|
||||
<div class="process-table-wrap">
|
||||
<table class="process-table" aria-describedby="process-manager-status">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="pid" data-i18n-zh="PID" data-i18n-en="PID">PID</button></th>
|
||||
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="user" data-i18n-zh="用户" data-i18n-en="User">用户</button></th>
|
||||
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="memoryBytes" data-i18n-zh="内存" data-i18n-en="Memory">内存</button></th>
|
||||
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="memoryPercent" data-i18n-zh="MEM%" data-i18n-en="MEM%">MEM%</button></th>
|
||||
<th scope="col" aria-sort="descending"><button class="process-sort" type="button" data-process-sort="cpuPercent" data-i18n-zh="CPU%" data-i18n-en="CPU%">CPU%</button></th>
|
||||
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="state" data-i18n-zh="状态" data-i18n-en="State">状态</button></th>
|
||||
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="time" data-i18n-zh="时间" data-i18n-en="Time">时间</button></th>
|
||||
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="command" data-i18n-zh="名称/命令行" data-i18n-en="Name / command">名称/命令行</button></th>
|
||||
<th scope="col" class="process-kill-column" data-i18n-zh="操作" data-i18n-en="Actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="process-table-body"></tbody>
|
||||
</table>
|
||||
<div id="process-manager-empty" class="process-manager-state" hidden data-i18n-zh="没有可显示的进程" data-i18n-en="No processes to display">没有可显示的进程</div>
|
||||
<div id="process-manager-error" class="process-manager-state error" role="alert" hidden></div>
|
||||
</div>
|
||||
<div class="process-statusbar">
|
||||
<span id="process-manager-status" role="status" aria-live="polite" data-i18n-zh="连接 SSH 后即可查看实时进程" data-i18n-en="Connect to SSH to view live processes">连接 SSH 后即可查看实时进程</span>
|
||||
<span><span data-i18n-zh="最后更新" data-i18n-en="Last update">最后更新</span> <strong id="process-updated">--</strong></span>
|
||||
</div>
|
||||
</section>
|
||||
<div id="event-log" class="event-log" role="tabpanel" aria-labelledby="event-toggle" tabindex="0" hidden aria-live="polite"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast-region" class="toast-region" aria-live="polite"></div>
|
||||
|
||||
<dialog id="host-key-dialog" class="host-key-dialog" aria-labelledby="host-key-title">
|
||||
<form method="dialog">
|
||||
<span id="host-key-icon" class="dialog-icon" aria-hidden="true">密钥</span>
|
||||
<div>
|
||||
<p id="host-key-eyebrow" class="eyebrow">主机身份</p>
|
||||
<h2 id="host-key-title">信任此 SSH 主机?</h2>
|
||||
<p id="host-key-description">服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt data-i18n-zh="目标" data-i18n-en="Target">目标</dt><dd id="host-key-target">--</dd></div>
|
||||
<div><dt data-i18n-zh="密钥类型" data-i18n-en="Key type">密钥类型</dt><dd id="host-key-type">--</dd></div>
|
||||
<div id="host-key-expected-row" hidden><dt data-i18n-zh="之前的指纹" data-i18n-en="Previous fingerprint">之前的指纹</dt><dd id="host-key-expected-fingerprint">--</dd></div>
|
||||
<div><dt data-i18n-zh="SHA-256 指纹" data-i18n-en="SHA-256 fingerprint">SHA-256 指纹</dt><dd id="host-key-fingerprint">--</dd></div>
|
||||
</dl>
|
||||
<label class="trust-check">
|
||||
<input id="remember-host-key" type="checkbox" checked />
|
||||
<span id="remember-host-key-label">下次记住并验证此指纹</span>
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button id="reject-host-key" class="secondary-button" value="reject" type="submit" data-i18n-zh="取消连接" data-i18n-en="Cancel">取消连接</button>
|
||||
<button id="accept-host-key" class="primary-button" value="accept" type="submit">信任并继续</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="process-kill-dialog" class="process-kill-dialog" aria-labelledby="process-kill-title">
|
||||
<form method="dialog">
|
||||
<span class="dialog-icon" aria-hidden="true">!</span>
|
||||
<div>
|
||||
<p class="eyebrow" data-i18n-zh="危险操作" data-i18n-en="Destructive action">危险操作</p>
|
||||
<h2 id="process-kill-title" data-i18n-zh="确认结束进程?" data-i18n-en="Terminate process?">确认结束进程?</h2>
|
||||
<p id="process-kill-description" data-i18n-zh="将通过 SSH 向远端主机发送 SIGTERM 信号。该操作不可撤销。" data-i18n-en="This will send a SIGTERM signal to the remote process via SSH. This action cannot be undone.">将通过 SSH 向远端主机发送 SIGTERM 信号。该操作不可撤销。</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt data-i18n-zh="PID" data-i18n-en="PID">PID</dt><dd id="process-kill-target-pid">--</dd></div>
|
||||
<div><dt data-i18n-zh="命令" data-i18n-en="Command">命令</dt><dd id="process-kill-target-command" class="process-kill-command">--</dd></div>
|
||||
</dl>
|
||||
<div id="process-kill-result" class="process-kill-result" role="status" aria-live="polite" hidden></div>
|
||||
<div class="dialog-actions">
|
||||
<button id="reject-process-kill" class="secondary-button" value="reject" type="submit" data-i18n-zh="取消" data-i18n-en="Cancel">取消</button>
|
||||
<button id="confirm-process-kill" class="primary-button is-danger" value="confirm" type="submit" data-i18n-zh="确定结束" data-i18n-en="Terminate">确定结束</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,143 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const CLOUDFLARE_API = 'https://api.cloudflare.com/client/v4';
|
||||
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function requireValue(value, name) {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) throw new Error(`缺少 ${name}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateName(value, name, pattern) {
|
||||
if (!pattern.test(value)) throw new Error(`${name} 格式无效:${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeDomain(value) {
|
||||
const domain = value?.trim().toLowerCase();
|
||||
if (!domain) return '';
|
||||
|
||||
const parsed = new URL(`https://${domain}`);
|
||||
if (parsed.hostname !== domain || parsed.port || parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
||||
throw new Error(`CUSTOM_DOMAIN 必须是纯域名,不能包含协议、端口或路径:${domain}`);
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
export function deploymentSettings(env = process.env) {
|
||||
const workerName = validateName(
|
||||
env.WORKER_NAME?.trim() || 'edgessh',
|
||||
'WORKER_NAME',
|
||||
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/,
|
||||
);
|
||||
const databaseName = validateName(
|
||||
env.D1_DATABASE_NAME?.trim() || `${workerName}-accounts`,
|
||||
'D1_DATABASE_NAME',
|
||||
/^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62}[A-Za-z0-9])?$/,
|
||||
);
|
||||
|
||||
return {
|
||||
accountId: requireValue(env.CLOUDFLARE_ACCOUNT_ID, 'CLOUDFLARE_ACCOUNT_ID'),
|
||||
apiToken: requireValue(env.CLOUDFLARE_API_TOKEN, 'CLOUDFLARE_API_TOKEN'),
|
||||
workerName,
|
||||
databaseName,
|
||||
customDomain: normalizeDomain(env.CUSTOM_DOMAIN),
|
||||
};
|
||||
}
|
||||
|
||||
async function cloudflareRequest(settings, pathname, init, fetchImpl) {
|
||||
const response = await fetchImpl(`${CLOUDFLARE_API}/accounts/${settings.accountId}${pathname}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.success) {
|
||||
const message = payload.errors?.map((error) => error.message).filter(Boolean).join('; ') || response.statusText;
|
||||
throw new Error(`Cloudflare API 请求失败(${response.status}):${message}`);
|
||||
}
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async function findDatabase(settings, fetchImpl) {
|
||||
const query = new URLSearchParams({ name: settings.databaseName, per_page: '100' });
|
||||
const databases = await cloudflareRequest(settings, `/d1/database?${query}`, undefined, fetchImpl);
|
||||
return databases.find((database) => database.name === settings.databaseName);
|
||||
}
|
||||
|
||||
export async function ensureDatabase(settings, fetchImpl = fetch) {
|
||||
const existing = await findDatabase(settings, fetchImpl);
|
||||
if (existing) return { id: existing.uuid, created: false };
|
||||
|
||||
try {
|
||||
const created = await cloudflareRequest(
|
||||
settings,
|
||||
'/d1/database',
|
||||
{ method: 'POST', body: JSON.stringify({ name: settings.databaseName }) },
|
||||
fetchImpl,
|
||||
);
|
||||
return { id: created.uuid, created: true };
|
||||
} catch (error) {
|
||||
// 并行部署可能在查询后率先创建同名数据库,重新查询即可继续复用该资源。
|
||||
const concurrentlyCreated = await findDatabase(settings, fetchImpl);
|
||||
if (concurrentlyCreated) return { id: concurrentlyCreated.uuid, created: false };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function replaceOnce(source, pattern, replacement, label) {
|
||||
if (!pattern.test(source)) throw new Error(`wrangler.toml 缺少 ${label} 配置`);
|
||||
return source.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
export function renderWranglerConfig(baseConfig, settings, databaseId) {
|
||||
let config = replaceOnce(baseConfig, /^name = ".*"$/m, `name = ${JSON.stringify(settings.workerName)}`, 'Worker name');
|
||||
config = replaceOnce(
|
||||
config,
|
||||
/^database_name = ".*"$/m,
|
||||
`database_name = ${JSON.stringify(settings.databaseName)}`,
|
||||
'D1 database_name',
|
||||
);
|
||||
config = replaceOnce(
|
||||
config,
|
||||
/^database_id = ".*"$/m,
|
||||
`database_id = ${JSON.stringify(databaseId)}`,
|
||||
'D1 database_id',
|
||||
);
|
||||
|
||||
if (settings.customDomain) {
|
||||
const routes = `routes = [\n { pattern = ${JSON.stringify(settings.customDomain)}, custom_domain = true }\n]`;
|
||||
config = replaceOnce(config, /^preview_urls = false$/m, `preview_urls = false\n\n${routes}`, 'preview_urls');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function prepareDeployment(env = process.env, fetchImpl = fetch) {
|
||||
const settings = deploymentSettings(env);
|
||||
const baseConfigPath = env.WRANGLER_BASE_CONFIG || path.join(PROJECT_ROOT, 'wrangler.toml');
|
||||
const deployConfigPath = env.WRANGLER_DEPLOY_CONFIG || path.join(PROJECT_ROOT, 'wrangler.deploy.toml');
|
||||
const database = await ensureDatabase(settings, fetchImpl);
|
||||
const baseConfig = await readFile(baseConfigPath, 'utf8');
|
||||
const deployConfig = renderWranglerConfig(baseConfig, settings, database.id);
|
||||
|
||||
await mkdir(path.dirname(deployConfigPath), { recursive: true });
|
||||
await writeFile(deployConfigPath, deployConfig, 'utf8');
|
||||
|
||||
console.log(`${database.created ? '已创建' : '已复用'} D1 数据库:${settings.databaseName}`);
|
||||
console.log(`Worker:${settings.workerName}`);
|
||||
console.log(settings.customDomain ? `自定义域名:${settings.customDomain}` : '自定义域名:未配置,仅发布 workers.dev');
|
||||
return { settings, database, deployConfigPath };
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
prepareDeployment().catch((error) => {
|
||||
console.error(`部署准备失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const CLOUDFLARE_API = 'https://api.cloudflare.com/client/v4';
|
||||
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const WRANGLER_BIN = path.join(PROJECT_ROOT, 'node_modules', 'wrangler', 'bin', 'wrangler.js');
|
||||
|
||||
function requireValue(value, name) {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) throw new Error(`缺少 ${name}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function pagesProjectSettings(env = process.env) {
|
||||
const projectName = env.DOCS_PROJECT_NAME?.trim() || 'edgessh-docs';
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]{0,56}[a-z0-9])?$/.test(projectName)) {
|
||||
throw new Error(`DOCS_PROJECT_NAME 格式无效:${projectName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: requireValue(env.CLOUDFLARE_ACCOUNT_ID, 'CLOUDFLARE_ACCOUNT_ID'),
|
||||
apiToken: requireValue(env.CLOUDFLARE_API_TOKEN, 'CLOUDFLARE_API_TOKEN'),
|
||||
projectName,
|
||||
};
|
||||
}
|
||||
|
||||
async function parseCloudflareResponse(response) {
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.success) {
|
||||
const message = payload.errors?.map((error) => error.message).filter(Boolean).join('; ') || response.statusText;
|
||||
throw new Error(`Cloudflare Pages API 请求失败(${response.status}):${message}`);
|
||||
}
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
export async function ensurePagesProject(settings, fetchImpl = fetch) {
|
||||
const endpoint = `${CLOUDFLARE_API}/accounts/${settings.accountId}/pages/projects/${settings.projectName}`;
|
||||
const headers = { Authorization: `Bearer ${settings.apiToken}`, 'Content-Type': 'application/json' };
|
||||
const existing = await fetchImpl(endpoint, { headers });
|
||||
|
||||
if (existing.ok) {
|
||||
return { project: await parseCloudflareResponse(existing), created: false };
|
||||
}
|
||||
if (existing.status !== 404) await parseCloudflareResponse(existing);
|
||||
|
||||
const created = await fetchImpl(`${CLOUDFLARE_API}/accounts/${settings.accountId}/pages/projects`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ name: settings.projectName, production_branch: 'main' }),
|
||||
});
|
||||
return { project: await parseCloudflareResponse(created), created: true };
|
||||
}
|
||||
|
||||
export async function preparePagesProject(env = process.env, fetchImpl = fetch) {
|
||||
const settings = pagesProjectSettings(env);
|
||||
const result = await ensurePagesProject(settings, fetchImpl);
|
||||
console.log(`${result.created ? '已创建' : '已复用'} Cloudflare Pages 项目:${settings.projectName}`);
|
||||
return { settings, ...result };
|
||||
}
|
||||
|
||||
function deployPages(settings, env = process.env) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
WRANGLER_BIN,
|
||||
'pages',
|
||||
'deploy',
|
||||
'.vitepress/dist',
|
||||
'--project-name',
|
||||
settings.projectName,
|
||||
'--branch',
|
||||
'main',
|
||||
'--commit-dirty=true',
|
||||
],
|
||||
{ cwd: path.join(PROJECT_ROOT, 'docs'), env, stdio: 'inherit' },
|
||||
);
|
||||
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`Cloudflare Pages 部署失败,退出码:${result.status}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
preparePagesProject()
|
||||
.then(({ settings }) => {
|
||||
if (process.argv.includes('--deploy')) deployPages(settings);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`文档站部署准备失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const WRANGLER_BIN = path.join(PROJECT_ROOT, 'node_modules', 'wrangler', 'bin', 'wrangler.js');
|
||||
|
||||
function requireSecret(value, name) {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) throw new Error(`缺少 ${name}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function accessTeamDomain(value) {
|
||||
const domain = requireSecret(value, 'ACCESS_TEAM_DOMAIN').toLowerCase();
|
||||
if (!/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(domain)) {
|
||||
throw new Error('ACCESS_TEAM_DOMAIN 必须是 Cloudflare Access Team Domain,且不含协议或路径');
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
function encryptionKey(value) {
|
||||
const key = requireSecret(value, 'ENCRYPTION_KEY');
|
||||
const decoded = Buffer.from(key, 'base64');
|
||||
if (decoded.length !== 32 || decoded.toString('base64') !== key) {
|
||||
throw new Error('ENCRYPTION_KEY 必须是 32 字节安全随机数的标准 Base64');
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function deploymentSecrets(env = process.env) {
|
||||
return {
|
||||
ACCESS_TEAM_DOMAIN: accessTeamDomain(env.ACCESS_TEAM_DOMAIN),
|
||||
ACCESS_AUD: requireSecret(env.ACCESS_AUD, 'ACCESS_AUD'),
|
||||
ENCRYPTION_KEY: encryptionKey(env.ENCRYPTION_KEY),
|
||||
};
|
||||
}
|
||||
|
||||
export function syncWorkerSecrets(env = process.env) {
|
||||
const secrets = deploymentSecrets(env);
|
||||
const deployConfigPath = env.WRANGLER_DEPLOY_CONFIG || path.join(PROJECT_ROOT, 'wrangler.deploy.toml');
|
||||
|
||||
// Secret 仅经标准输入交给 Wrangler,避免在命令参数、配置文件或日志中留下明文。
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[WRANGLER_BIN, 'secret', 'bulk', '--config', deployConfigPath],
|
||||
{
|
||||
cwd: PROJECT_ROOT,
|
||||
env,
|
||||
input: JSON.stringify(secrets),
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`Wrangler Secret 同步失败,退出码:${result.status}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
syncWorkerSecrets();
|
||||
} catch (error) {
|
||||
console.error(`Worker Secret 同步失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
+21
-8
@@ -1,5 +1,8 @@
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export class HostEncryptionKeyError extends Error {}
|
||||
export class HostPayloadDecryptionError extends Error {}
|
||||
|
||||
export function base64(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
@@ -13,8 +16,13 @@ export async function digest(value: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function encryptionKey(secret: string): Promise<CryptoKey> {
|
||||
const key = unbase64(secret);
|
||||
if (key.length !== 32) throw new Error('ENCRYPTION_KEY must be 32 bytes encoded as base64');
|
||||
let key: Uint8Array<ArrayBuffer>;
|
||||
try {
|
||||
key = unbase64(secret);
|
||||
} catch {
|
||||
throw new HostEncryptionKeyError();
|
||||
}
|
||||
if (key.length !== 32 || base64(key) !== secret) throw new HostEncryptionKeyError();
|
||||
return crypto.subtle.importKey('raw', key, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
@@ -31,10 +39,15 @@ export async function encryptHost(value: unknown, secret: string, accountId: str
|
||||
}
|
||||
|
||||
export async function decryptHost<T>(value: string, secret: string, accountId: string, hostId: string): Promise<T> {
|
||||
const [version, iv, ciphertext] = value.split('.');
|
||||
if (version !== 'v1') throw new Error('Unsupported ciphertext version');
|
||||
const plaintext = await crypto.subtle.decrypt({
|
||||
name: 'AES-GCM', iv: unbase64(iv), additionalData: encoder.encode(`edgessh:v1:${accountId}:${hostId}`),
|
||||
}, await encryptionKey(secret), unbase64(ciphertext));
|
||||
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
|
||||
try {
|
||||
const [version, iv, ciphertext] = value.split('.');
|
||||
if (version !== 'v1') throw new HostPayloadDecryptionError();
|
||||
const plaintext = await crypto.subtle.decrypt({
|
||||
name: 'AES-GCM', iv: unbase64(iv), additionalData: encoder.encode(`edgessh:v1:${accountId}:${hostId}`),
|
||||
}, await encryptionKey(secret), unbase64(ciphertext));
|
||||
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof HostEncryptionKeyError) throw error;
|
||||
throw new HostPayloadDecryptionError();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { parseConnectMessage, type Env } from '../types';
|
||||
import { decryptHost, encryptHost } from './crypto';
|
||||
import { APIError, json, readJSON } from './http';
|
||||
import { locateHost, type HostLocation } from './location';
|
||||
import type { SystemInfo } from '../backend/system-info';
|
||||
import { parseConnectMessage, type Env } from '../types.ts';
|
||||
import { decryptHost, encryptHost } from './crypto.ts';
|
||||
import { APIError, json, readJSON } from './http.ts';
|
||||
import { locateHost, type HostLocation } from './location.ts';
|
||||
import type { SystemInfo } from '../backend/system-info.ts';
|
||||
|
||||
export interface HostPayload {
|
||||
name: string;
|
||||
@@ -82,6 +82,7 @@ function validate(body: Record<string, unknown>, previous?: HostPayload): HostPa
|
||||
}
|
||||
|
||||
export async function hostsRoute(request: Request, env: Env, accountId: string, pathname: string): Promise<Response> {
|
||||
if (!env.DB) throw new APIError('主机存储尚未配置,请检查 DB 绑定并执行 D1 migration。', 503);
|
||||
const match = /^\/api\/hosts(?:\/([a-f0-9-]{36})(?:\/(credentials|location|system))?)?$/.exec(pathname);
|
||||
if (!match) throw new APIError('接口不存在。', 404);
|
||||
const [, id, action] = match;
|
||||
|
||||
+12
-3
@@ -1,7 +1,13 @@
|
||||
import { jsonError, secureResponse } from '../http-security';
|
||||
import { jsonError, secureResponse } from '../http-security.ts';
|
||||
import { HostEncryptionKeyError, HostPayloadDecryptionError } from './crypto.ts';
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(message: string, readonly status = 400) { super(message); }
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status = 400) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function json(value: unknown, status = 200, headers?: HeadersInit): Response {
|
||||
@@ -34,5 +40,8 @@ export async function readJSON(request: Request, maxBytes = 160 * 1024): Promise
|
||||
}
|
||||
|
||||
export function apiFailure(error: unknown): Response {
|
||||
return error instanceof APIError ? jsonError(error.message, error.status) : jsonError('服务暂时不可用,请稍后重试。', 500);
|
||||
if (error instanceof APIError) return jsonError(error.message, error.status);
|
||||
if (error instanceof HostEncryptionKeyError) return jsonError('主机存储尚未正确配置,请检查 ENCRYPTION_KEY。', 503);
|
||||
if (error instanceof HostPayloadDecryptionError) return jsonError('主机资料无法解密,请确认 ENCRYPTION_KEY 未被更换。', 503);
|
||||
return jsonError('服务暂时不可用,请稍后重试。', 500);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
deploymentSettings,
|
||||
ensureDatabase,
|
||||
renderWranglerConfig,
|
||||
} from '../scripts/prepare-deployment.mjs';
|
||||
import { deploymentSecrets } from '../scripts/sync-worker-secrets.mjs';
|
||||
|
||||
const encryptionKey = Buffer.alloc(32, 7).toString('base64');
|
||||
|
||||
const settings = deploymentSettings({
|
||||
CLOUDFLARE_ACCOUNT_ID: 'account-id',
|
||||
CLOUDFLARE_API_TOKEN: 'api-token',
|
||||
WORKER_NAME: 'my-edgessh',
|
||||
D1_DATABASE_NAME: 'my-edgessh-accounts',
|
||||
CUSTOM_DOMAIN: 'ssh.example.com',
|
||||
});
|
||||
|
||||
function cloudflareResponse(result: unknown, status = 200): Response {
|
||||
return Response.json({ success: status < 400, result, errors: [] }, { status });
|
||||
}
|
||||
|
||||
test('deployment settings use stable defaults and optional workers.dev deployment', () => {
|
||||
assert.deepEqual(
|
||||
deploymentSettings({ CLOUDFLARE_ACCOUNT_ID: 'account-id', CLOUDFLARE_API_TOKEN: 'api-token' }),
|
||||
{
|
||||
accountId: 'account-id',
|
||||
apiToken: 'api-token',
|
||||
workerName: 'edgessh',
|
||||
databaseName: 'edgessh-accounts',
|
||||
customDomain: '',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('deployment secrets are read from the Actions environment', () => {
|
||||
assert.deepEqual(
|
||||
deploymentSecrets({
|
||||
ACCESS_TEAM_DOMAIN: ' team.cloudflareaccess.com ',
|
||||
ACCESS_AUD: ' access-audience ',
|
||||
ENCRYPTION_KEY: ` ${encryptionKey} `,
|
||||
}),
|
||||
{
|
||||
ACCESS_TEAM_DOMAIN: 'team.cloudflareaccess.com',
|
||||
ACCESS_AUD: 'access-audience',
|
||||
ENCRYPTION_KEY: encryptionKey,
|
||||
},
|
||||
);
|
||||
assert.throws(() => deploymentSecrets({}), /缺少 ACCESS_TEAM_DOMAIN/);
|
||||
assert.throws(() => deploymentSecrets({
|
||||
ACCESS_TEAM_DOMAIN: 'https://team.cloudflareaccess.com',
|
||||
ACCESS_AUD: 'access-audience',
|
||||
ENCRYPTION_KEY: encryptionKey,
|
||||
}), /ACCESS_TEAM_DOMAIN 必须/);
|
||||
assert.throws(() => deploymentSecrets({
|
||||
ACCESS_TEAM_DOMAIN: 'team.cloudflareaccess.com',
|
||||
ACCESS_AUD: 'access-audience',
|
||||
ENCRYPTION_KEY: 'not-a-32-byte-key',
|
||||
}), /ENCRYPTION_KEY 必须/);
|
||||
});
|
||||
|
||||
test('existing D1 database is reused without a create request', async () => {
|
||||
const requests: Array<{ url: string; method: string }> = [];
|
||||
const result = await ensureDatabase(settings, async (input, init) => {
|
||||
requests.push({ url: String(input), method: init?.method || 'GET' });
|
||||
return cloudflareResponse([{ name: settings.databaseName, uuid: 'existing-database-id' }]);
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { id: 'existing-database-id', created: false });
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].method, 'GET');
|
||||
});
|
||||
|
||||
test('missing D1 database is created once', async () => {
|
||||
const methods: string[] = [];
|
||||
const result = await ensureDatabase(settings, async (_input, init) => {
|
||||
const method = init?.method || 'GET';
|
||||
methods.push(method);
|
||||
return method === 'POST'
|
||||
? cloudflareResponse({ name: settings.databaseName, uuid: 'new-database-id' })
|
||||
: cloudflareResponse([]);
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { id: 'new-database-id', created: true });
|
||||
assert.deepEqual(methods, ['GET', 'POST']);
|
||||
});
|
||||
|
||||
test('generated Wrangler config contains resolved resources and custom domain', async () => {
|
||||
const baseConfig = await readFile(new URL('../wrangler.toml', import.meta.url), 'utf8');
|
||||
const config = renderWranglerConfig(baseConfig, settings, 'resolved-database-id');
|
||||
|
||||
assert.match(config, /^name = "my-edgessh"$/m);
|
||||
assert.match(config, /^database_name = "my-edgessh-accounts"$/m);
|
||||
assert.match(config, /^database_id = "resolved-database-id"$/m);
|
||||
assert.match(config, /pattern = "ssh\.example\.com", custom_domain = true/);
|
||||
});
|
||||
|
||||
test('public Wrangler config never contains maintainer resources', async () => {
|
||||
const baseConfig = await readFile(new URL('../wrangler.toml', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(baseConfig, /^database_id = "00000000-0000-0000-0000-000000000000"$/m);
|
||||
assert.doesNotMatch(baseConfig, /^routes\s*=|custom_domain\s*=|ssh\.dltwcnm\.ccwu\.cc/m);
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { encryptHost, decryptHost, base64 } from '../src/accounts/crypto.ts';
|
||||
import {
|
||||
encryptHost,
|
||||
decryptHost,
|
||||
base64,
|
||||
HostEncryptionKeyError,
|
||||
HostPayloadDecryptionError,
|
||||
} from '../src/accounts/crypto.ts';
|
||||
import { apiFailure } from '../src/accounts/http.ts';
|
||||
|
||||
const key = base64(crypto.getRandomValues(new Uint8Array(32)));
|
||||
|
||||
@@ -21,9 +28,23 @@ test('ciphertext is bound to both Access identity and host id', async () => {
|
||||
|
||||
test('wrong keys, malformed secrets and tampered ciphertext fail closed', async () => {
|
||||
const encrypted = await encryptHost({ password: 'secret' }, key, 'owner', 'host');
|
||||
await assert.rejects(decryptHost(encrypted, base64(crypto.getRandomValues(new Uint8Array(32))), 'owner', 'host'));
|
||||
await assert.rejects(encryptHost({}, base64(new Uint8Array(16)), 'owner', 'host'));
|
||||
await assert.rejects(
|
||||
decryptHost(encrypted, base64(crypto.getRandomValues(new Uint8Array(32))), 'owner', 'host'),
|
||||
HostPayloadDecryptionError,
|
||||
);
|
||||
await assert.rejects(encryptHost({}, base64(new Uint8Array(16)), 'owner', 'host'), HostEncryptionKeyError);
|
||||
await assert.rejects(encryptHost({}, `${key.slice(0, -1)}A`, 'owner', 'host'), HostEncryptionKeyError);
|
||||
const parts = encrypted.split('.');
|
||||
parts[2] = (parts[2][0] === 'A' ? 'B' : 'A') + parts[2].slice(1);
|
||||
await assert.rejects(decryptHost(parts.join('.'), key, 'owner', 'host'));
|
||||
await assert.rejects(decryptHost(parts.join('.'), key, 'owner', 'host'), HostPayloadDecryptionError);
|
||||
});
|
||||
|
||||
test('host storage failures return actionable errors without exposing internals', async () => {
|
||||
const invalidKey = apiFailure(new HostEncryptionKeyError());
|
||||
assert.equal(invalidKey.status, 503);
|
||||
assert.deepEqual(await invalidKey.json(), { error: '主机存储尚未正确配置,请检查 ENCRYPTION_KEY。' });
|
||||
|
||||
const unreadablePayload = apiFailure(new HostPayloadDecryptionError());
|
||||
assert.equal(unreadablePayload.status, 503);
|
||||
assert.deepEqual(await unreadablePayload.json(), { error: '主机资料无法解密,请确认 ENCRYPTION_KEY 未被更换。' });
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { base64 } from '../src/accounts/crypto.ts';
|
||||
import { APIError } from '../src/accounts/http.ts';
|
||||
import { hostsRoute } from '../src/accounts/hosts.ts';
|
||||
import type { Env } from '../src/types.ts';
|
||||
|
||||
interface StoredHost {
|
||||
id: string;
|
||||
account_id: string;
|
||||
encrypted_payload: string;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
class FakeStatement {
|
||||
private values: unknown[] = [];
|
||||
private readonly database: FakeDatabase;
|
||||
private readonly sql: string;
|
||||
|
||||
constructor(database: FakeDatabase, sql: string) {
|
||||
this.database = database;
|
||||
this.sql = sql;
|
||||
}
|
||||
|
||||
bind(...values: unknown[]): FakeStatement {
|
||||
this.values = values;
|
||||
return this;
|
||||
}
|
||||
|
||||
async all<T>(): Promise<{ results: T[] }> {
|
||||
if (!this.sql.startsWith('SELECT id, encrypted_payload, updated_at FROM hosts WHERE account_id')) throw new Error(`Unexpected all(): ${this.sql}`);
|
||||
const accountId = this.values[0];
|
||||
const rows = [...this.database.rows.values()]
|
||||
.filter((row) => row.account_id === accountId)
|
||||
.sort((left, right) => right.updated_at - left.updated_at)
|
||||
.map(({ id, encrypted_payload, updated_at }) => ({ id, encrypted_payload, updated_at }) as T);
|
||||
return { results: rows };
|
||||
}
|
||||
|
||||
async first<T>(): Promise<T | null> {
|
||||
if (!this.sql.startsWith('SELECT id, encrypted_payload, updated_at FROM hosts WHERE id')) throw new Error(`Unexpected first(): ${this.sql}`);
|
||||
const [id, accountId] = this.values;
|
||||
const row = this.database.rows.get(String(id));
|
||||
if (!row || row.account_id !== accountId) return null;
|
||||
return { id: row.id, encrypted_payload: row.encrypted_payload, updated_at: row.updated_at } as T;
|
||||
}
|
||||
|
||||
async run(): Promise<{ meta: { changes: number } }> {
|
||||
if (this.sql.startsWith('INSERT INTO hosts')) {
|
||||
const [id, accountId, encryptedPayload, updatedAt] = this.values;
|
||||
const count = [...this.database.rows.values()].filter((row) => row.account_id === accountId).length;
|
||||
if (count >= 200) return { meta: { changes: 0 } };
|
||||
this.database.rows.set(String(id), {
|
||||
id: String(id), account_id: String(accountId), encrypted_payload: String(encryptedPayload), updated_at: Number(updatedAt),
|
||||
});
|
||||
return { meta: { changes: 1 } };
|
||||
}
|
||||
if (this.sql.startsWith('UPDATE hosts SET encrypted_payload = ?, updated_at = ?')) {
|
||||
const [encryptedPayload, updatedAt, id, accountId] = this.values;
|
||||
const row = this.database.rows.get(String(id));
|
||||
if (!row || row.account_id !== accountId) return { meta: { changes: 0 } };
|
||||
row.encrypted_payload = String(encryptedPayload);
|
||||
row.updated_at = Number(updatedAt);
|
||||
return { meta: { changes: 1 } };
|
||||
}
|
||||
if (this.sql.startsWith('DELETE FROM hosts')) {
|
||||
const [id, accountId] = this.values;
|
||||
const row = this.database.rows.get(String(id));
|
||||
return { meta: { changes: row?.account_id === accountId && this.database.rows.delete(String(id)) ? 1 : 0 } };
|
||||
}
|
||||
throw new Error(`Unexpected run(): ${this.sql}`);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDatabase {
|
||||
readonly rows = new Map<string, StoredHost>();
|
||||
|
||||
prepare(sql: string): FakeStatement {
|
||||
return new FakeStatement(this, sql);
|
||||
}
|
||||
}
|
||||
|
||||
const key = base64(crypto.getRandomValues(new Uint8Array(32)));
|
||||
|
||||
function environment(database: FakeDatabase): Env {
|
||||
return { DB: database as unknown as D1Database, ENCRYPTION_KEY: key } as Env;
|
||||
}
|
||||
|
||||
function request(method: string, body?: unknown): Request {
|
||||
return new Request('https://ssh.example.com/api/hosts', {
|
||||
method,
|
||||
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const input = {
|
||||
name: '测试主机', group: '个人', host: '192.0.2.10', port: 22, username: 'root',
|
||||
authMethod: 'password', password: 'correct horse battery staple', fingerprint: '',
|
||||
initialCommand: '', termType: 'xterm-256color', encoding: 'utf-8',
|
||||
};
|
||||
|
||||
test('correctly configured host storage creates, reads and updates an encrypted host', async () => {
|
||||
const database = new FakeDatabase();
|
||||
const env = environment(database);
|
||||
|
||||
const createdResponse = await hostsRoute(request('POST', input), env, 'account-a', '/api/hosts');
|
||||
assert.equal(createdResponse.status, 201);
|
||||
const created = (await createdResponse.json() as { host: Record<string, unknown> }).host;
|
||||
assert.equal(created.name, input.name);
|
||||
assert.equal(created.hasCredential, true);
|
||||
assert.equal('password' in created, false);
|
||||
|
||||
const stored = database.rows.get(String(created.id));
|
||||
assert.ok(stored);
|
||||
assert.equal(stored.encrypted_payload.includes(input.password), false);
|
||||
assert.equal(stored.encrypted_payload.includes(input.host), false);
|
||||
|
||||
const ownList = await hostsRoute(request('GET'), env, 'account-a', '/api/hosts');
|
||||
assert.equal((await ownList.json() as { hosts: unknown[] }).hosts.length, 1);
|
||||
const otherList = await hostsRoute(request('GET'), env, 'account-b', '/api/hosts');
|
||||
assert.deepEqual(await otherList.json(), { hosts: [] });
|
||||
|
||||
const updatedResponse = await hostsRoute(
|
||||
request('PUT', { ...input, name: '已更新主机', password: undefined }),
|
||||
env,
|
||||
'account-a',
|
||||
`/api/hosts/${created.id}`,
|
||||
);
|
||||
assert.equal(updatedResponse.status, 200);
|
||||
assert.equal((await updatedResponse.json() as { host: { name: string } }).host.name, '已更新主机');
|
||||
|
||||
const credentials = await hostsRoute(request('POST'), env, 'account-a', `/api/hosts/${created.id}/credentials`);
|
||||
assert.deepEqual(await credentials.json(), { password: input.password });
|
||||
await assert.rejects(
|
||||
hostsRoute(request('POST'), env, 'account-b', `/api/hosts/${created.id}/credentials`),
|
||||
(error) => error instanceof APIError && error.status === 404,
|
||||
);
|
||||
});
|
||||
|
||||
test('missing D1 binding returns an actionable configuration error', async () => {
|
||||
await assert.rejects(
|
||||
hostsRoute(request('POST', input), { ENCRYPTION_KEY: key } as Env, 'account-a', '/api/hosts'),
|
||||
(error) => error instanceof APIError && error.status === 503 && error.message.includes('DB 绑定'),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
ensurePagesProject,
|
||||
pagesProjectSettings,
|
||||
} from '../scripts/prepare-pages-project.mjs';
|
||||
|
||||
const settings = pagesProjectSettings({
|
||||
CLOUDFLARE_ACCOUNT_ID: 'account-id',
|
||||
CLOUDFLARE_API_TOKEN: 'api-token',
|
||||
DOCS_PROJECT_NAME: 'my-edgessh-docs',
|
||||
});
|
||||
|
||||
function cloudflareResponse(result: unknown, status = 200): Response {
|
||||
return Response.json(
|
||||
{ success: status < 400, result, errors: status < 400 ? [] : [{ message: 'not found' }] },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
test('Pages settings use a stable default project name', () => {
|
||||
assert.deepEqual(
|
||||
pagesProjectSettings({ CLOUDFLARE_ACCOUNT_ID: 'account-id', CLOUDFLARE_API_TOKEN: 'api-token' }),
|
||||
{ accountId: 'account-id', apiToken: 'api-token', projectName: 'edgessh-docs' },
|
||||
);
|
||||
});
|
||||
|
||||
test('an existing Pages project is reused', async () => {
|
||||
const methods: string[] = [];
|
||||
const result = await ensurePagesProject(settings, async (_input, init) => {
|
||||
methods.push(init?.method || 'GET');
|
||||
return cloudflareResponse({ name: settings.projectName });
|
||||
});
|
||||
|
||||
assert.equal(result.created, false);
|
||||
assert.equal(result.project.name, settings.projectName);
|
||||
assert.deepEqual(methods, ['GET']);
|
||||
});
|
||||
|
||||
test('a missing Pages project is created with main as production branch', async () => {
|
||||
const requests: Array<{ method: string; body?: string }> = [];
|
||||
const result = await ensurePagesProject(settings, async (_input, init) => {
|
||||
const method = init?.method || 'GET';
|
||||
requests.push({ method, body: init?.body ? String(init.body) : undefined });
|
||||
return method === 'GET'
|
||||
? cloudflareResponse(null, 404)
|
||||
: cloudflareResponse({ name: settings.projectName, production_branch: 'main' });
|
||||
});
|
||||
|
||||
assert.equal(result.created, true);
|
||||
assert.deepEqual(requests.map(({ method }) => method), ['GET', 'POST']);
|
||||
assert.deepEqual(JSON.parse(requests[1].body || '{}'), {
|
||||
name: settings.projectName,
|
||||
production_branch: 'main',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
const theme = read('../frontend/src/workspace-theme.css');
|
||||
const main = read('../frontend/src/main.ts');
|
||||
const html = read('../frontend/index.html');
|
||||
|
||||
test('工作台品牌适配在上游样式之后加载,首次使用与总览一样为浅色', () => {
|
||||
assert.ok(main.indexOf("import './workspace-theme.css'") > main.indexOf("import './style.css'"));
|
||||
assert.match(html, /<html[^>]+data-theme="light"/);
|
||||
assert.match(main, /localStorage\.getItem\(THEME_STORAGE_KEY\)/);
|
||||
});
|
||||
|
||||
test('工作台复用总览品牌变量,不覆盖总览组件', () => {
|
||||
for (const token of ['--home-bg', '--home-surface', '--home-font', '--home-mono', '--home-accent', '--home-accent-hover']) {
|
||||
assert.ok(theme.includes(`var(${token})`), `${token} 应复用总览定义`);
|
||||
}
|
||||
assert.doesNotMatch(theme, /#dashboard\s*[.{:]/);
|
||||
assert.match(theme, /:root\[data-theme='dark'\]/);
|
||||
assert.match(theme, /#app \.live-orb\.connected\s*\{[^}]*var\(--workspace-success\)/);
|
||||
});
|
||||
|
||||
test('适配依赖的关键上游选择器仍然存在,终端输出不被品牌换色', () => {
|
||||
// 只约束适配接缝,不锁死上游整份样式,以免正常更新产生无意义冲突。
|
||||
for (const name of ['terminal-pane', 'terminal-toolbar', 'terminal-stage', 'terminal-empty', 'workspace-dock', 'resource-metrics']) {
|
||||
assert.ok(html.includes(`class="${name}"`) || html.includes(`class="${name} `), `${name} 已变更,请复核适配层`);
|
||||
}
|
||||
assert.match(main, /background: '#080d12'/);
|
||||
assert.match(main, /green: '#69e6b4'/);
|
||||
assert.doesNotMatch(theme, /\.xterm[^{]*\{/);
|
||||
});
|
||||
+1
-5
@@ -4,10 +4,6 @@ compatibility_date = "2026-07-01"
|
||||
workers_dev = true
|
||||
preview_urls = false
|
||||
|
||||
routes = [
|
||||
{ pattern = "ssh.dltwcnm.ccwu.cc", custom_domain = true }
|
||||
]
|
||||
|
||||
[build]
|
||||
command = "npm run build:web"
|
||||
|
||||
@@ -31,5 +27,5 @@ new_sqlite_classes = ["SSHSessionDO"]
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "edgessh-accounts"
|
||||
database_id = "2c1b7a95-a5d6-4a72-a8d2-4132b74aa268"
|
||||
database_id = "00000000-0000-0000-0000-000000000000"
|
||||
migrations_dir = "migrations"
|
||||
Reference in new issue
Block a user