diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a86dff5..eeae2b1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..df3e292 --- /dev/null +++ b/.github/workflows/docs.yml @@ -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 diff --git a/.gitignore b/.gitignore index 69b8cd0..10afa8c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ node_modules/ dist/ .wrangler/ +docs/.vitepress/cache/ +docs/.vitepress/dist/ +wrangler.deploy.toml coverage/ *.log .dev.vars diff --git a/.hallmark/log.json b/.hallmark/log.json new file mode 100644 index 0000000..eca26b8 --- /dev/null +++ b/.hallmark/log.json @@ -0,0 +1,9 @@ +[ + { + "date": "2026-09-20", + "macrostructure": "Workbench", + "theme": "Coral", + "enrichment": "real product capture", + "brief": "EdgeSSH VitePress deployment documentation" + } +] diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index b86e7b3..6de89c0 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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` 和 `-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`,或默认的 `-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 | `-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` | `-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` 不能绕过应用层认证。 diff --git a/README.md b/README.md index 63764ff..cc39003 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Apache 2.0

-[功能特性](#功能特性) · [界面预览](#界面预览) · [快速开始](#快速开始) · [本地开发](#本地开发) · [安全与限制](#安全与限制) +[文档网站](https://edgessh-docs.pages.dev) · [Actions 部署](https://edgessh-docs.pages.dev/deploy/actions) · [功能特性](#功能特性) · [界面预览](#界面预览) · [本地开发](#本地开发) · [安全与限制](#安全与限制) @@ -32,6 +32,18 @@ + + + + + +
+ EdgeChat
+ 基于 Cloudflare 的开源自部署团队聊天系统 +
+ 查看开源项目 → +
+ ## 功能特性 @@ -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 | `-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。 ## 本地开发 diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 0000000..d54ecab --- /dev/null +++ b/docs/.vitepress/config.ts @@ -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', + }, + }, +}); diff --git a/docs/.vitepress/theme/components/ScreenshotPlaceholder.vue b/docs/.vitepress/theme/components/ScreenshotPlaceholder.vue new file mode 100644 index 0000000..d7d514d --- /dev/null +++ b/docs/.vitepress/theme/components/ScreenshotPlaceholder.vue @@ -0,0 +1,29 @@ + + + diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..77197bf --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -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; diff --git a/docs/.vitepress/theme/style.css b/docs/.vitepress/theme/style.css new file mode 100644 index 0000000..bb325da --- /dev/null +++ b/docs/.vitepress/theme/style.css @@ -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; + } +} diff --git a/docs/.vitepress/theme/tokens.css b/docs/.vitepress/theme/tokens.css new file mode 100644 index 0000000..ac7a9a9 --- /dev/null +++ b/docs/.vitepress/theme/tokens.css @@ -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); +} diff --git a/docs/ZERO_TRUST.md b/docs/ZERO_TRUST.md index ed5d723..c247a0f 100644 --- a/docs/ZERO_TRUST.md +++ b/docs/ZERO_TRUST.md @@ -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 登录已失效” diff --git a/docs/deploy/actions.md b/docs/deploy/actions.md new file mode 100644 index 0000000..ee863f5 --- /dev/null +++ b/docs/deploy/actions.md @@ -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 +``` + + + +## 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` | `-accounts` | 自定义 D1 数据库名称 | + +`DOCS_PROJECT_NAME` 只属于文档站的 `Deploy docs` workflow,不是 EdgeSSH 应用部署的必填项。 + + + +## 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,并绑定必填配置中的自定义域名。 + + + +### 怎样判断成功 + +等待运行记录显示绿色成功标记,并展开 **Prepare Cloudflare resources**、**Sync Worker secrets** 与对应的部署步骤。资源准备日志中应能看到: + +```text +已创建 D1 数据库:edgessh-accounts +``` + +或后续部署中的: + +```text +已复用 D1 数据库:edgessh-accounts +``` + +日志还应显示 Worker 名称和自定义域名,但不应出现 API Token、Access 参数或加密密钥。 + + + +::: 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` 稳定。修改数据库名称会切换到另一个数据库,不会自动迁移旧数据。 diff --git a/docs/deploy/api-token.md b/docs/deploy/api-token.md new file mode 100644 index 0000000..d2c5783 --- /dev/null +++ b/docs/deploy/api-token.md @@ -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,不要为了省事授予无关资源权限。 + + + +## 保存到 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。 diff --git a/docs/deploy/runtime-secrets.md b/docs/deploy/runtime-secrets.md new file mode 100644 index 0000000..4f6dc59 --- /dev/null +++ b/docs/deploy/runtime-secrets.md @@ -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 批量同步它们。 + + + +## 生成 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`,否则已有主机资料将无法解密。 diff --git a/docs/deploy/troubleshooting.md b/docs/deploy/troubleshooting.md new file mode 100644 index 0000000..0a57496 --- /dev/null +++ b/docs/deploy/troubleshooting.md @@ -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 日志。 diff --git a/docs/deploy/verification.md b/docs/deploy/verification.md new file mode 100644 index 0000000..12565b5 --- /dev/null +++ b/docs/deploy/verification.md @@ -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 私钥。 + +保存后刷新页面,确认记录仍然存在。再编辑非凭据字段,确认留空不会意外清除原凭据。 + + + +## 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` 表。 + +完成所有检查后,才可以把部署标记为可正常使用。 diff --git a/docs/deploy/zero-trust.md b/docs/deploy/zero-trust.md new file mode 100644 index 0000000..e82c58d --- /dev/null +++ b/docs/deploy/zero-trust.md @@ -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` 完全相同的域名。 + + + +## 3. 添加 Allow 策略 + +在应用的 Access policies 中创建策略: + +| 项目 | 推荐值 | +| --- | --- | +| Policy name | `EdgeSSH Admin` | +| Action | `Allow` | +| Rule type | `Include` | +| Selector | `Emails` | +| Value | 你的完整管理员邮箱 | + +需要多名管理员时,逐个添加明确邮箱。只有在你确实希望整个企业邮箱域都能管理服务器时,才使用整域授权。 + + + +## 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 策略内的邮箱不能进入。 +- 允许的邮箱能够完成认证。 + + + +## 常见问题 + +### One-time PIN 收不到邮件 + +先确认策略中的 Emails 与登录邮箱完全一致。Cloudflare 不会向未被策略允许的邮箱发送验证码,但页面可能仍显示已发送,避免泄露访问名单。 + +### 登录后提示 Access 登录已失效 + +通常是以下三项没有对应同一个应用: + +- 正在访问的自定义域名。 +- `ACCESS_TEAM_DOMAIN` 所属 Zero Trust 组织。 +- `ACCESS_AUD` 所属 Self-hosted 应用。 + +### 页面没有出现 Access 登录 + +确认 Access 应用 Public hostname 与 `CUSTOM_DOMAIN` 完全一致。不要把 `workers.dev` 诊断地址当作正式入口。 diff --git a/docs/development/contributing.md b/docs/development/contributing.md new file mode 100644 index 0000000..6b7a171 --- /dev/null +++ b/docs/development/contributing.md @@ -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。修改与分发时请保留适用的版权和许可证声明。 diff --git a/docs/development/docs-site.md b/docs/development/docs-site.md new file mode 100644 index 0000000..391590d --- /dev/null +++ b/docs/development/docs-site.md @@ -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`。 + + + +## 自动触发 + +推送到 `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 +``` diff --git a/docs/development/local.md b/docs/development/local.md new file mode 100644 index 0000000..6f73229 --- /dev/null +++ b/docs/development/local.md @@ -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 目标验收。 diff --git a/docs/development/workspace-theme.md b/docs/development/workspace-theme.md new file mode 100644 index 0000000..a124940 --- /dev/null +++ b/docs/development/workspace-theme.md @@ -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 变量、类名或结构变化时,仍需要逐项验证。 diff --git a/docs/guide/overview.md b/docs/guide/overview.md new file mode 100644 index 0000000..a2f5887 --- /dev/null +++ b/docs/guide/overview.md @@ -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),避免把未支持的认证方式当作配置问题。 diff --git a/docs/guide/requirements.md b/docs/guide/requirements.md new file mode 100644 index 0000000..2a70b2f --- /dev/null +++ b/docs/guide/requirements.md @@ -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)。 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ca1d7b2 --- /dev/null +++ b/docs/index.md @@ -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 +--- + +
+
+ EdgeSSH 主机总览与地球视图 +
主机资料加密保存在 D1;从总览进入终端、SFTP 和进程面板。
+
+
+ 打开浏览器,连接你的服务器。 +

EdgeSSH 运行于 Cloudflare Workers。浏览器只连接 Worker,由独立 Durable Object 会话通过 TCP Socket 连接公网 SSH 服务。

+ 查看安全边界 +
+
+ +
+

一条可以重复执行的部署路径

+
+
+ 01 + 准备 Cloudflare +

接入域名,创建最小权限 API Token,并配置 Access 应用。

+
+
+ 02 + 填写 GitHub +

保存账户 ID、自定义域名与四项 Secret,名称通常保持默认。

+
+
+ 03 + 运行 Deploy +

Action 自动检查项目、创建或复用 D1、执行迁移并发布 Worker。

+
+
+ 04 + 验证自动同步 +

确认 Action 已同步 Access 参数与固定加密密钥,再完成验收。

+
+
+
+ +
+
+

先明确边界,再保存凭据

+
+
+

EdgeSSH 面向个人管理员,不提供本地注册或匿名入口。Cloudflare Access 负责身份认证,Worker 在应用内再次校验 Access JWT。它不是端到端加密:Worker 在建立 SSH 会话时会处理明文凭据,因此 Cloudflare 账户和 Worker Secret 管理权限都属于信任边界。

+ 开始完整部署 +
+
diff --git a/docs/public/images/edgessh-dashboard-demo.png b/docs/public/images/edgessh-dashboard-demo.png new file mode 100644 index 0000000..ed846da Binary files /dev/null and b/docs/public/images/edgessh-dashboard-demo.png differ diff --git a/docs/public/screenshots/.gitkeep b/docs/public/screenshots/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/public/screenshots/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/public/screenshots/01-fork-repository.png b/docs/public/screenshots/01-fork-repository.png new file mode 100644 index 0000000..e6b7abd Binary files /dev/null and b/docs/public/screenshots/01-fork-repository.png differ diff --git a/docs/public/screenshots/02-cloudflare-api-token-permissions.png b/docs/public/screenshots/02-cloudflare-api-token-permissions.png new file mode 100644 index 0000000..8410da5 Binary files /dev/null and b/docs/public/screenshots/02-cloudflare-api-token-permissions.png differ diff --git a/docs/public/screenshots/03-github-actions-variables.png b/docs/public/screenshots/03-github-actions-variables.png new file mode 100644 index 0000000..be9d595 Binary files /dev/null and b/docs/public/screenshots/03-github-actions-variables.png differ diff --git a/docs/public/screenshots/04-run-deploy-workflow.png b/docs/public/screenshots/04-run-deploy-workflow.png new file mode 100644 index 0000000..518ab4b Binary files /dev/null and b/docs/public/screenshots/04-run-deploy-workflow.png differ diff --git a/docs/public/screenshots/08-github-actions-secrets.png b/docs/public/screenshots/08-github-actions-secrets.png new file mode 100644 index 0000000..be9d595 Binary files /dev/null and b/docs/public/screenshots/08-github-actions-secrets.png differ diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 0000000..a45de64 --- /dev/null +++ b/docs/reference/configuration.md @@ -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` | `-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 校验,没有匿名开发绕过开关。 diff --git a/docs/reference/limits.md b/docs/reference/limits.md new file mode 100644 index 0000000..ea17560 --- /dev/null +++ b/docs/reference/limits.md @@ -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 校验或主机指纹确认来实现快捷绕过。 diff --git a/docs/reference/screenshots.md b/docs/reference/screenshots.md new file mode 100644 index 0000000..f5583ca --- /dev/null +++ b/docs/reference/screenshots.md @@ -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 + +``` + +添加图片后: + +```md + +``` + +提交前运行 `npm run docs:build`,确认没有断链或资源错误。 diff --git a/docs/reference/security.md b/docs/reference/security.md new file mode 100644 index 0000000..7e59e5e --- /dev/null +++ b/docs/reference/security.md @@ -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..` 格式密文。 +- 列表响应不返回连接凭据。 + +`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 信息。 +- 真实操作前核对服务器指纹。 diff --git a/docs/usage/files-and-processes.md b/docs/usage/files-and-processes.md new file mode 100644 index 0000000..5bf8591 --- /dev/null +++ b/docs/usage/files-and-processes.md @@ -0,0 +1,44 @@ +# 文件与进程 + +终端旁的辅助面板通过独立 SSH 通道运行。它们与主终端共享当前受认证会话,但拥有各自的附着令牌和消息边界。 + +## SFTP 文件管理 + +当前实现基于 SFTP v3,支持: + +- 浏览远端目录。 +- 上传与下载单个文件。 +- 新建目录。 +- 重命名文件或目录。 +- 删除文件。 +- 删除空目录。 + +单文件上传与下载上限为 64 MiB。大文件、目录递归传输或需要断点续传时,建议在终端中使用服务器已有工具。 + +::: warning 先用测试目录验收 +首次部署后,在专门测试目录中完成上传、下载、重命名与删除。不要直接使用 `/etc`、应用数据目录或生产发布目录。 +::: + +## 进程面板 + +进程面板通过远端命令收集: + +- CPU 使用情况。 +- 系统负载。 +- 内存与 Swap。 +- 进程列表。 + +显示结果取决于远端系统提供的命令和输出格式。系统过于精简或权限受限时,部分指标可能不可用,但不应影响主终端。 + +## 系统识别 + +EdgeSSH 会读取远端系统信息,识别常见 Linux 发行版、版本与架构。探测只用于界面展示,不会安装软件或修改系统文件。 + +## 辅助面板故障 + +遇到面板无法加载时: + +1. 先确认主终端仍可执行命令。 +2. 检查 SSH 用户是否有权读取目标目录或进程信息。 +3. 查看浏览器中的用户可读错误,不要上传包含凭据的完整网络记录。 +4. 重新进入工作台,排除辅助通道的临时断开。 diff --git a/docs/usage/hosts.md b/docs/usage/hosts.md new file mode 100644 index 0000000..165017b --- /dev/null +++ b/docs/usage/hosts.md @@ -0,0 +1,37 @@ +# 管理主机 + +通过 Cloudflare Access 登录后,首页会显示当前身份拥有的主机。不同 Access `sub` 的数据彼此隔离,同一身份最多保存 200 台主机。 + +## 新建主机 + +选择新增主机,填写: + +| 字段 | 说明 | +| --- | --- | +| 名称 | 用于列表识别,例如“东京测试机” | +| 地址 | 公网域名或公网 IP,不带协议 | +| 端口 | SSH 端口,默认通常为 `22` | +| 用户名 | 目标服务器的 SSH 用户 | +| 分组 | 可选,用于筛选 | +| 认证方式 | 密码或未加密 OpenSSH 私钥 | +| 终端编码 | UTF-8、GB18030 或 Big5 | + +保存地址时,Worker 会解析公网 IP 并尝试获取城市信息。定位失败不会阻止保存或连接;地球上的点位只表示记录位置,不表示服务器在线。 + +## 凭据更新规则 + +编辑主机时,凭据输入留空表示保留原值。切换认证方式时,应明确填写新方式所需凭据并保存。 + +主机列表接口不会返回密码或私钥。建立连接前,Worker 才会解密当前记录,并为一次性会话票据准备连接信息。 + +## 搜索与分组 + +使用搜索查找名称、地址或标签,并用分组筛选减少列表范围。地球视图和主机列表都是完整入口:点选地球标记或列表项都可以进入工作台。 + +## 刷新位置 + +服务器迁移或 DNS 指向变化后,可以从主机编辑界面刷新定位。刷新会重新解析公网地址并更新加密资料,不会检测服务器是否在线。 + +## 删除主机 + +删除操作只移除当前身份的这条加密记录,不会关闭服务器、删除服务器文件或修改 SSH 配置。若对应工作台仍在使用,先退出会话再删除记录。 diff --git a/docs/usage/terminal.md b/docs/usage/terminal.md new file mode 100644 index 0000000..d46b70a --- /dev/null +++ b/docs/usage/terminal.md @@ -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 等工具托管。 diff --git a/docs/wrangler.toml b/docs/wrangler.toml new file mode 100644 index 0000000..7edafdf --- /dev/null +++ b/docs/wrangler.toml @@ -0,0 +1,3 @@ +name = "edgessh-docs" +pages_build_output_dir = ".vitepress/dist" +compatibility_date = "2026-09-20" diff --git a/frontend/index.html b/frontend/index.html index c51b424..ab721a4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,10 @@ - + - + EdgeSSH diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 6e4019a..4dbc76c 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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'; diff --git a/frontend/src/workspace-theme.css b/frontend/src/workspace-theme.css new file mode 100644 index 0000000..bb4d687 --- /dev/null +++ b/frontend/src/workspace-theme.css @@ -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; } +} diff --git a/package-lock.json b/package-lock.json index 6925ab4..b7e08ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,12 +20,321 @@ "flag-icons": "^7.5.0", "typescript": "^5.9.2", "vite": "^7.0.6", + "vitepress": "^1.6.4", "wrangler": "^4.134.0" }, "engines": { "node": ">=22.12.0" } }, + "node_modules/@algolia/abtesting": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.25.0.tgz", + "integrity": "sha512-rSTin9Uta23uaewYVQEp8XI9T3iA/zrg0/1G2vhf8oFFDxFL5vybnZ5IQwsVAg4JpKxPX4/WYNKdcfWrZymk7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.59.0.tgz", + "integrity": "sha512-bm2XN0hCSMYwStSsCBT0/PUB2BDxoyR1Lnub3c392HMEy9bi8PUSW8vR6zltVEKWG2t4PQFWMD5C07bmJxGgPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.59.0.tgz", + "integrity": "sha512-XOFPOTa69WuqHR6c5tMgnUUwwqQgNSzMpxmhrgA9KmxRf8WIqEa0cokHJvohk5CYb7CZx0xeSL6Bk2IUJ7Lv7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.59.0.tgz", + "integrity": "sha512-PC8ipLOYFKRTfIUY1J3FJxS6ryzWziaXmIX9/sNMoUR8L+XhF7hX2QAeUI87YVl5zujvlYTSOb+HAIqKXDjyHQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.59.0.tgz", + "integrity": "sha512-yFNcCMM5fHiyoR0HuxMrzy+VjDcmhFUZTm2IJ2DHwGsVH3B5SEob4zTmeEZ3j/AZqNnmrJOCKl/tJnBg7+84bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.59.0.tgz", + "integrity": "sha512-GYja6HkDt2VrQhWmB2cLx3Z5fDwI9no7q+xwCWcFrTPm5CLH9QZvK0XLO9co1FcNIDxxkMaP3AXM39/XcecEOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.59.0.tgz", + "integrity": "sha512-Wofg7bMpWh8N5qDDZs0wy6whc+KMmdNsxgrIGp9Ug2s1Bka0uq7AjyckdxReKPHLA0Q1qH8X/SNh0wn2t2X2Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.59.0.tgz", + "integrity": "sha512-fHnALZfbEnODczGk14Y/1YBRApp6UEpZUTexGcMUPzY7RDc7q4HN2Y6jh0KUG8Jo9B+q+wOoQEc3ziR0ErbuFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.59.0.tgz", + "integrity": "sha512-Fa38s1mHgoaLCT117sfJ6P78rtxUt93CYxBYpq1VOIcslaF+cH+1h5uikAPsKfxLnsuEDZHiBiaI6eNPTsQMRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.59.0.tgz", + "integrity": "sha512-NyNsRSqM2tF1MX7ZGw/j4rduoEJiQ5wfvbSo/CFVdEzYCjyxbGFRPOZyS/GETJG9rk1TdHOq8NZqKqk/u1fm4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.59.0.tgz", + "integrity": "sha512-nXBK2uygWtvbCOffMWqqfjjcEtp9enDKY5/2Pw/Hhw8VVaOyK2ThbGK04bNX/Le+qoK1VHYkodWGVTKfw0Otkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.59.0.tgz", + "integrity": "sha512-yb+4afX/zja8QwX0KmV4/ae2kyYkRknsE79CRusYS2U5D8qwn2wq0cn1x12f3tm3F3+5FgmDdbuGdQTuCLSKMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.59.0.tgz", + "integrity": "sha512-Lp52TmpA1QtNmdHzs505Xwf4tgII7RAapkDSF9AAtWIBETcGaTIaXci4Rd9nS6SdaWK1BWTSqAPp5wzh6UN3Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.59.0.tgz", + "integrity": "sha512-YYHLEs5rC6oRTFwT7bJaKBR9NSFzikeVHGAT1ffATmUQzR8XqyhHXjoieAUoR7fBU6I5cfy1FVeh5GoRgriuzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://mirrors.huaweicloud.com/repository/npm/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -157,6 +466,57 @@ "node": ">=12" } }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -610,6 +970,23 @@ "node": ">=18" } }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.97", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.97.tgz", + "integrity": "sha512-Z6PmM6jvWVU7rmLzqbxCLJA7SeqCnK6lMHXtjyzHPLZebwuoojBEcHHycppahDzEeN9xujRkrV5J8QpOhMyNrg==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1544,6 +1921,93 @@ "win32" ] }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -1571,6 +2035,321 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", + "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.10" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", + "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.10", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", + "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.11.0", "resolved": "https://mirrors.huaweicloud.com/repository/npm/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", @@ -1592,6 +2371,42 @@ "addons/*" ] }, + "node_modules/algoliasearch": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.59.0.tgz", + "integrity": "sha512-wUXzaeI7B526W4y1gFg3lcxgDZ67XSgRJIiellYWOas/pLpO7rOtxm9Gr2E/C8aiSDXIgx1q5TdSsvK67Uakqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.25.0", + "@algolia/client-abtesting": "5.59.0", + "@algolia/client-analytics": "5.59.0", + "@algolia/client-common": "5.59.0", + "@algolia/client-insights": "5.59.0", + "@algolia/client-personalization": "5.59.0", + "@algolia/client-query-suggestions": "5.59.0", + "@algolia/client-search": "5.59.0", + "@algolia/ingestion": "1.59.0", + "@algolia/monitoring": "1.59.0", + "@algolia/recommend": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://mirrors.huaweicloud.com/repository/npm/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1599,6 +2414,50 @@ "dev": true, "license": "MIT" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -1613,6 +2472,36 @@ "url": "https://opencollective.com/express" } }, + "node_modules/copy-anything": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.1.tgz", + "integrity": "sha512-AoT6Imdr98feSpFfmFwTFN73ccdr7uFPf27cBCgYvyyRyn1BzLRxMvrHNmwXO5LJMddRy4Rdhw2b1h7vSMKsEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1623,6 +2512,40 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -1675,6 +2598,13 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://mirrors.huaweicloud.com/repository/npm/fdir/-/fdir-6.5.0.tgz", @@ -1700,6 +2630,16 @@ "dev": true, "license": "MIT" }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://mirrors.huaweicloud.com/repository/npm/fsevents/-/fsevents-2.3.3.tgz", @@ -1715,6 +2655,62 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/jose": { "version": "6.2.12", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", @@ -1734,12 +2730,145 @@ "node": ">=6" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/mappo": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/mappo/-/mappo-0.3.0.tgz", "integrity": "sha512-xk2pmX+k9nxyL6C1k2zP4oD4t6mZRWKHaiK/wV9Ac+33+qrEPNElLMwBOfOQQZxGvABV0qFKEn1adlzEeW7ZGQ==", "license": "MIT" }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/miniflare": { "version": "5.20260917.0-alpha", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260917.0-alpha.tgz", @@ -1758,6 +2887,20 @@ "node": ">=22.0.0" } }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -1777,6 +2920,18 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://mirrors.huaweicloud.com/repository/npm/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -1791,6 +2946,13 @@ "dev": true, "license": "MIT" }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://mirrors.huaweicloud.com/repository/npm/picocolors/-/picocolors-1.1.1.tgz", @@ -1812,9 +2974,9 @@ } }, "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://mirrors.huaweicloud.com/repository/npm/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -1832,7 +2994,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1840,6 +3002,70 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.3", "resolved": "https://mirrors.huaweicloud.com/repository/npm/rollup/-/rollup-4.62.3.tgz", @@ -1885,6 +3111,14 @@ "fsevents": "~2.3.2" } }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -1948,6 +3182,23 @@ } } }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/simple-icons": { "version": "15.22.0", "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-15.22.0.tgz", @@ -1977,6 +3228,55 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -1990,6 +3290,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://mirrors.huaweicloud.com/repository/npm/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2007,6 +3314,17 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2049,6 +3367,109 @@ "pathe": "^2.0.3" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://mirrors.huaweicloud.com/repository/npm/vite/-/vite-7.3.6.tgz", @@ -2124,6 +3545,574 @@ } } }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vitepress/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/vitepress/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitepress/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/workerd": { "version": "1.20260917.1", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260917.1.tgz", @@ -2227,6 +4216,17 @@ "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 167baf4..7bd79ea 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts-tmp-local-index.html b/scripts-tmp-local-index.html new file mode 100644 index 0000000..c5ecfd5 --- /dev/null +++ b/scripts-tmp-local-index.html @@ -0,0 +1,613 @@ + + + + + + + + + + + + EdgeSSH · Private workspace + + +
+ + + + + + + + + + + + +
名称大小类型修改时间权限用户组
+ + + + +
+ 连接 SSH 后即可管理文件 + +
+ + + + + + + + +
+ + +
+ +
+

主机身份

+

信任此 SSH 主机?

+

服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。

+
+
+
目标
--
+
密钥类型
--
+ +
SHA-256 指纹
--
+
+ +
+ + +
+
+
+ + +
+ +
+

危险操作

+

确认结束进程?

+

将通过 SSH 向远端主机发送 SIGTERM 信号。该操作不可撤销。

+
+
+
PID
--
+
命令
--
+
+ +
+ + +
+
+
+ + + + diff --git a/scripts/prepare-deployment.mjs b/scripts/prepare-deployment.mjs new file mode 100644 index 0000000..77a7565 --- /dev/null +++ b/scripts/prepare-deployment.mjs @@ -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; + }); +} diff --git a/scripts/prepare-pages-project.mjs b/scripts/prepare-pages-project.mjs new file mode 100644 index 0000000..6f4a502 --- /dev/null +++ b/scripts/prepare-pages-project.mjs @@ -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; + }); +} diff --git a/scripts/sync-worker-secrets.mjs b/scripts/sync-worker-secrets.mjs new file mode 100644 index 0000000..894d3e1 --- /dev/null +++ b/scripts/sync-worker-secrets.mjs @@ -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; + } +} diff --git a/src/accounts/crypto.ts b/src/accounts/crypto.ts index 76ee252..2763f61 100644 --- a/src/accounts/crypto.ts +++ b/src/accounts/crypto.ts @@ -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 { } async function encryptionKey(secret: string): Promise { - const key = unbase64(secret); - if (key.length !== 32) throw new Error('ENCRYPTION_KEY must be 32 bytes encoded as base64'); + let key: Uint8Array; + 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(value: string, secret: string, accountId: string, hostId: string): Promise { - 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(); + } } diff --git a/src/accounts/hosts.ts b/src/accounts/hosts.ts index 907a0e8..845811b 100644 --- a/src/accounts/hosts.ts +++ b/src/accounts/hosts.ts @@ -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, previous?: HostPayload): HostPa } export async function hostsRoute(request: Request, env: Env, accountId: string, pathname: string): Promise { + 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; diff --git a/src/accounts/http.ts b/src/accounts/http.ts index 11c1ce8..114c6ed 100644 --- a/src/accounts/http.ts +++ b/src/accounts/http.ts @@ -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); } diff --git a/tests/deployment.test.ts b/tests/deployment.test.ts new file mode 100644 index 0000000..17b499b --- /dev/null +++ b/tests/deployment.test.ts @@ -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); +}); diff --git a/tests/encryption.test.ts b/tests/encryption.test.ts index 2f02248..098bc47 100644 --- a/tests/encryption.test.ts +++ b/tests/encryption.test.ts @@ -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 未被更换。' }); }); diff --git a/tests/hosts.test.ts b/tests/hosts.test.ts new file mode 100644 index 0000000..bf87a0f --- /dev/null +++ b/tests/hosts.test.ts @@ -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(): 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(): Promise { + 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(); + + 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 }).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 绑定'), + ); +}); diff --git a/tests/pages-deployment.test.ts b/tests/pages-deployment.test.ts new file mode 100644 index 0000000..f0063f8 --- /dev/null +++ b/tests/pages-deployment.test.ts @@ -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', + }); +}); diff --git a/tests/workspace-theme.test.ts b/tests/workspace-theme.test.ts new file mode 100644 index 0000000..2f8ab5c --- /dev/null +++ b/tests/workspace-theme.test.ts @@ -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, /]+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[^{]*\{/); +}); diff --git a/wrangler.toml b/wrangler.toml index 8055438..8901864 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -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"