From 3fa5257d93252d5fcba7327800a355e270702477 Mon Sep 17 00:00:00 2001 From: gdz66601 Date: Mon, 21 Sep 2026 23:08:50 +0800 Subject: [PATCH] feat(deploy): automate Access bootstrap and preserve Worker secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自动配置 Access、OTP 与邮箱授权,保留 Worker 密钥并预留 GitHub IdP 扩展。 --- .github/workflows/deploy.yml | 15 +- DEPLOYMENT.md | 195 ++++++++++--------------- README.md | 85 ++++------- docs/ZERO_TRUST.md | 14 +- scripts/cloudflare-access.ts | 117 +++++++++++++++ scripts/cloudflare-api.ts | 71 +++++++++ scripts/deploy.ts | 33 ++++- scripts/deployment-config.ts | 43 ++++-- scripts/deployment-secrets.ts | 44 ++++++ tests/deployment-bootstrap.test.ts | 227 +++++++++++++++++++++++++++++ tests/deployment.test.ts | 18 ++- tsconfig.scripts.json | 3 +- 12 files changed, 661 insertions(+), 204 deletions(-) create mode 100644 scripts/cloudflare-access.ts create mode 100644 scripts/cloudflare-api.ts create mode 100644 scripts/deployment-secrets.ts create mode 100644 tests/deployment-bootstrap.test.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 69427d8..94f0a71 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,11 @@ on: branches: - main workflow_dispatch: + inputs: + admin_email: + description: 管理员邮箱(首次必填;也可预先保存为 ADMIN_EMAIL 变量) + required: false + type: string concurrency: group: production-deploy @@ -21,6 +26,8 @@ jobs: env: CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + ADMIN_EMAIL: ${{ inputs.admin_email || secrets.ADMIN_EMAIL || vars.ADMIN_EMAIL }} + ACCESS_IDP_IDS: ${{ vars.ACCESS_IDP_IDS }} WORKER_NAME: ${{ vars.WORKER_NAME }} D1_DATABASE_NAME: ${{ vars.D1_DATABASE_NAME }} D1_DATABASE_ID: ${{ vars.D1_DATABASE_ID }} @@ -39,20 +46,16 @@ jobs: - name: Install dependencies run: npm ci - # 先校验配置,避免构建完成后才发现缺少 Secret,或创建不完整的远端资源。 + # 本地格式先校验;账户发现与远端资源配置在检查通过后执行。 - name: Validate deployment settings run: npm run deploy:validate env: ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} - ACCESS_TEAM_DOMAIN: ${{ secrets.ACCESS_TEAM_DOMAIN }} - ACCESS_AUD: ${{ secrets.ACCESS_AUD }} - name: Check project run: npm run check - - name: Provision D1, migrate, sync secrets and deploy Worker + - name: Configure Access, provision D1, migrate and deploy Worker run: npm run deploy env: ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} - ACCESS_TEAM_DOMAIN: ${{ secrets.ACCESS_TEAM_DOMAIN }} - ACCESS_AUD: ${{ secrets.ACCESS_AUD }} diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 4636b67..540bf02 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,151 +1,106 @@ -# EdgeSSH 第一版:部署与验收 +# EdgeSSH 部署与验收 -## 架构与边界 +## 三项准备 -- 单管理员,无本地用户名、密码、注册、验证码或用户表。身份认证由 Cloudflare Zero Trust Access 完成。 -- Access 应用策略只允许管理员的明确邮箱/身份,不能使用**所有人(Everyone)**、**绕过(Bypass)**或允许整个邮箱域的宽泛策略。 -- Worker 使用 `jose` 校验 `Cf-Access-Jwt-Assertion` 的 RS256 签名、issuer、audience、有效期、subject 与邮箱。不信任单独的邮箱请求头。所有主机 API、SSH 票据和 WebSocket 附着均需认证。 -- D1 中每条主机记录绑定 Access `sub`。AES-256-GCM 加密整个主机资料(包括地址、密码/私钥、指纹和城市),随机 96-bit IV,AAD 绑定账户及记录 ID。 -- 密文格式 `v1..`。数据库只明文保存 ID、账户 ID、更新时间。列表响应不包含凭据,连接时才请求解密后的凭据,并仅放在浏览器内存中。 -- 当前不是端到端加密:Worker 在 SSH 连接时需要处理明文凭据。具有 Worker 和机密(Secret)管理权限的人属于信任边界。 -- 首次保存/修改地址时,Worker 解析公网 IP,优先向 `https://ipwho.is` 查询城市,被 Cloudflare 共享出口限流或超时时回退至 GeoJS,不发送 SSH 用户名、凭据或命令。定位允许 A/AAAA 单类查询失败并在其他公网地址间回退,位置与实际查询 IP 一同存入加密 payload;前端可随时强制重新定位,失败不阻止保存或连接。 -- 前端仍为 Vite + TypeScript,工作台继续复用原 SSH/SFTP/进程实现。地球使用 Mappo 的陆地掩码与 Canvas 球面投影,无 Three.js 等大型依赖;进入工作台后停止地球动画。 +1. 在 Cloudflare **启用 Zero Trust**,完成团队域名与计划初始化。组织开通涉及账户确认,不由脚本代办。 +2. Fork 本仓库,启用 GitHub Actions,在 **Settings > Secrets and variables > Actions** 保存 `CLOUDFLARE_API_TOKEN` Secret。 +3. 在 **Actions > Deploy > Run workflow** 输入管理员邮箱,运行并等待摘要给出访问地址。可将邮箱保存为 `ADMIN_EMAIL` Variable,省去重复输入。 -## 必需配置 +无需手动创建 Access 应用、OTP、D1,也无需抄录 Account ID、Team Domain 或 AUD。默认入口是 `https://edgessh.<账户子域>.workers.dev`。没有自定义域名也能使用。 -| 名称 | 配置位置 | 示例值 | 含义 | -| --- | --- | --- | --- | -| `DB` | `wrangler.toml` 的 D1 binding | `DB` | 主机资料数据库,优先复用已有资源 | -| `SSH_SESSIONS` | `wrangler.toml` 的 Durable Object binding | `SSH_SESSIONS` | SSH 会话隔离,保留现有类名及迁移历史 | -| `ASSETS` | `wrangler.toml` 的静态资源 binding | `ASSETS` | 前端构建产物,不需要手动创建 | -| `ACCESS_TEAM_DOMAIN` | GitHub Actions 机密(Secret) | `my-team.cloudflareaccess.com` | 团队域名,部署时同步为 Worker 机密 | -| `ACCESS_AUD` | GitHub Actions 机密(Secret) | `012345…`(常见外观) | 此 Access 应用的应用受众 (AUD) 标签(Application Audience (AUD) Tag),必须原样复制实际值,部署时同步为 Worker 机密 | -| `ENCRYPTION_KEY` | GitHub Actions 机密(Secret) | `AbCd…=`(44 字符 Base64) | 32 字节安全随机数的标准 Base64,部署时同步为 Worker 机密;示例仅表示格式,必须随机生成 | -| `CONNECT_TIMEOUT_MS` | 可选普通变量 | `10000` | 建连超时;仓库已有默认值,通常不要额外配置 | +## API Token 权限 -`DB`、`SSH_SESSIONS` 与 `ASSETS` 是绑定,不是环境变量。运行时 Secret 只有 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD` 与 `ENCRYPTION_KEY` 三项;部署流程还需要账户 ID 和 API Token。工作流不会把三个运行时 Secret 写入临时文件或命令行参数。 +以 Cloudflare **Edit Cloudflare Workers** 模板为起点,保留部署所需权限,并补齐以下账户权限。控制台的 Edit/Read 对应 API 文档的 Write/Read。 -## Cloudflare 控制台配置 +| 账户权限 | 级别 | 用途 | +| --- | --- | --- | +| Workers Scripts | Edit | Worker、Durable Object、Secret 和子域部署 | +| Workers KV Storage | Edit | 保留官方 Workers 模板的部署权限 | +| Account Settings | Read | 自动发现账户 | +| D1 | Edit | 查找/创建数据库、检查旧数据与执行迁移 | +| Access: Apps and Policies | Edit | 查找/创建 Access 应用及邮箱策略 | +| Access: Organizations, Identity Providers, and Groups | Edit | 获取团队域名、查找/创建 OTP | -### 1. 确定资源名称 +账户资源只选择实际部署账户。若 Token 可访问多个账户,设置 Actions Variable `CLOUDFLARE_ACCOUNT_ID`,脚本不会猜测目标账户。 -1. 默认 Worker 名称为 `edgessh`,默认 D1 名称为 `edgessh-accounts`。需要隔离多个实例时,可分别设置 Actions 变量 `WORKER_NAME` 与 `D1_DATABASE_NAME`。 -2. 工作流会在目标账户中按名称查找 D1:存在就复用,不存在才创建。后续部署会继续复用同一资源,不需要把账户专属 `database_id` 写入仓库。 -3. 只有需要绑定一个已知数据库时才设置可选变量 `D1_DATABASE_ID`;工作流会先确认该 ID 属于目标账户,验证失败时不会另建空库替代。 -4. 数据表不需要在控制台手动建立。GitHub Actions 会执行远程 migration,只添加项目所需 schema,不清空已有数据。 +只用 `workers.dev` 不需要自定义域名的 Zone 权限。使用 `CUSTOM_DOMAIN` 时,还需模板的 **Zone > Workers Routes: Edit、Zone: Read**,并将区域范围限定到该域名所在 Zone。域名必须已经由同账户的 Cloudflare 管理。 -### 2. 配置 Zero Trust Access +API Token 只存 GitHub Secret,不放普通变量、代码或命令行输入框。不要将 Token 填到 Run workflow 的邮箱字段。 -1. 进入 **Zero Trust > 访问控制(Access controls)> 应用程序(Applications)**,添加一个**自托管和私有应用(Self-hosted and private)**。 -2. 应用域名填写最终访问 EdgeSSH 的实际域名。默认可使用 `.<你的 Workers 子域>.workers.dev`;此时 `CUSTOM_DOMAIN` 必须删除或留空。只有确实绑定 `ssh.example.com` 这类自定义域名时才设置 `CUSTOM_DOMAIN`。 -3. 添加**允许(Allow)**策略,只包含管理员的明确邮箱或身份。不要使用**所有人(Everyone)**、**绕过(Bypass)**或允许整个邮箱域的宽泛规则。 -4. 在 Zero Trust 的团队设置中找到**团队域(Team Domain)**,保存不带 `https://` 和路径的域名,例如 `my-team.cloudflareaccess.com`。 -5. 在 Access 应用详情中复制**应用受众 (AUD) 标签(Application Audience (AUD) Tag)**。不要把应用 ID、客户端 ID(Client ID)或策略 ID 当作 AUD。 +## 自动执行顺序 -### 3. 创建部署用 API 令牌(API Token) +1. 校验本地配置,运行类型检查、测试、前端构建和 Wrangler dry-run。 +2. 自动发现唯一账户(显式账户 ID 优先),读取现有 Worker Secret **名称**,不尝试读取密钥明文。 +3. 默认读取账户 `workers.dev` 子域;未注册时自动注册确定性名称。已有子域不改名,避免影响其他 Worker。 +4. 读取 Zero Trust 团队域;按精确 hostname 复用 Access 自托管应用,或创建应用及明确邮箱的 Allow 策略。 +5. 新应用默认复用/创建 One-time PIN;存在的应用不重写登录方式和人工策略。 +6. 按 ID 或名称复用 D1,不存在才创建;指定 ID 不存在时直接失败,不用新空库替代。 +7. 已有 `ENCRYPTION_KEY` 则保留;没有密钥且 D1 没有主机资料时,用安全随机数生成 32 字节密钥。 +8. 执行远程 migration,仅应用增量 schema,不清空数据。 +9. 新增 Secret 只通过标准输入交给 Wrangler;部署 Worker,复核三个运行时 Secret 的名称,并将入口写入运行摘要。 -1. 在 Cloudflare 个人资料的**API 令牌(API Tokens)**页面创建令牌(Token),选择官方**编辑 Cloudflare Workers(Edit Cloudflare Workers)**模板作为起点。 -2. 在模板权限之外补充**账户(Account)**的 `D1: Edit` 权限,供工作流(workflow)执行远程迁移(migration)。 -3. 保留模板中的 Worker 权限;只使用 `workers.dev` 时无需为自定义域名增加目标**区域(Zone)**权限。设置 `CUSTOM_DOMAIN` 时才保留相应区域权限。 -4. **账户资源(Account Resources)**只选择实际部署账户;如使用自定义域名,**区域资源(Zone Resources)**也只选择对应域名,不要使用全账户、全站点范围。 -5. 令牌(Token)创建后只会完整显示一次,将它直接保存为 GitHub Actions 机密(Secret),不要写进仓库或 Cloudflare Worker 变量。 +生产任务通过 concurrency 串行运行,失败可修正原因后重跑,已创建的资源会复用。请勿用多个仓库同时管理同一个 Worker。 -Cloudflare 账户 ID 可在控制面板(Dashboard)的账户概览或 Worker 概览中复制。它不是机密(Secret),可保存为 GitHub Actions 变量(Variable)。 +设置自定义域名时关闭备用 `workers.dev` 入口,所有部署关闭 preview URL。Worker 内仍校验 JWT 的签名、issuer、audience、期限、subject 与邮箱;缺少认证不降级为匿名 SSH。 -### 4. 配置 GitHub Actions +## 可选配置 -在 GitHub 仓库进入**设置(Settings)> 机密和变量(Secrets and variables)> Actions**: +| 名称 | GitHub 位置 | 默认/示例 | +| --- | --- | --- | +| `ADMIN_EMAIL` | Variable,兼容 Secret | `you@example.com`;Run workflow 输入优先 | +| `CLOUDFLARE_ACCOUNT_ID` | Variable,兼容 Secret | 仅多账户 Token 需要指定 | +| `WORKER_NAME` | Variable | `edgessh` | +| `D1_DATABASE_NAME` | Variable | `-accounts` | +| `D1_DATABASE_ID` | Variable | 指定已有 D1 UUID,不填则按名称查找 | +| `CUSTOM_DOMAIN` | Variable,兼容 Secret | `ssh.example.com`;Secret 优先;`*.workers.dev` 不可填 | +| `ACCESS_IDP_IDS` | Variable | 新应用采用的 IdP UUID,多个用逗号分隔 | +| `ENCRYPTION_KEY` | Secret,仅恢复/迁移使用 | 仅 Worker 尚无密钥时使用;已有密钥不会覆盖 | -| 名称 | GitHub 类型 | 示例值 | 值 | -| --- | --- | --- | --- | -| `CLOUDFLARE_ACCOUNT_ID` | 变量(Variable) | `0123456789abcdef0123456789abcdef` | Cloudflare 账户 ID | -| `CLOUDFLARE_API_TOKEN` | 机密(Secret) | `Cloudflare 生成的令牌` | 上一步创建的最小权限 API 令牌(API Token) | -| `ACCESS_TEAM_DOMAIN` | 机密(Secret) | `my-team.cloudflareaccess.com` | Zero Trust 团队域(Team Domain),不带协议和路径 | -| `ACCESS_AUD` | 机密(Secret) | `012345…`(常见外观) | 实际访问域名对应 Access 应用的 AUD,必须原样复制实际值 | -| `ENCRYPTION_KEY` | 机密(Secret) | `AbCd…=`(44 字符 Base64) | 32 字节安全随机数的标准 Base64,只在首次部署时生成;不要使用示例文本 | - -可选部署值: - -| 名称 | GitHub 类型 | 示例值 | 何时填写 | -| --- | --- | --- | --- | -| `WORKER_NAME` | 变量(Variable) | `my-edgessh` | 需要覆盖默认 Worker 名 `edgessh` 时 | -| `D1_DATABASE_NAME` | 变量(Variable) | `my-edgessh-accounts` | 需要指定 D1 名称时 | -| `D1_DATABASE_ID` | 变量(Variable) | `00000000-0000-4000-8000-000000000001` | 需要复用一个已知 D1 ID 时 | -| `CUSTOM_DOMAIN` | 变量(Variable)或机密(Secret) | `ssh.example.com` | 仅绑定自定义域名时;使用 `*.workers.dev` 时必须留空 | - -`CUSTOM_DOMAIN` 推荐保存为普通变量,因为域名不是敏感信息;工作流也兼容 Secret,并在两处同时存在时优先使用 Secret。不填写时,Worker 直接发布到该账户的 `workers.dev` 域名;这不会影响 D1、Durable Object 或 Access JWT 校验。 - -进入 **Actions > Deploy > 运行工作流(Run workflow)** 完成首次部署。工作流(workflow)会校验配置、检查项目、创建或复用 D1、应用迁移、通过标准输入同步 Worker 机密,并创建或更新 Worker。 +`DB`、`SSH_SESSIONS`、`ASSETS` 是资源绑定,不是需要用户创建的变量。`CONNECT_TIMEOUT_MS` 已有默认值 `10000`。 -### 5. 生成并保管加密密钥 +## 密钥生命周期与旧版升级 -`ENCRYPTION_KEY` 应由可信的密码管理器或本机安全随机数工具生成,解码后必须正好为 32 字节。将它与 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD` 一起保存为 GitHub Actions 机密(Secret)。工作流负责同步它们;不要为 `DB`、`SSH_SESSIONS` 或 `ASSETS` 创建同名变量。`CONNECT_TIMEOUT_MS` 已由 `wrangler.toml` 设置为 `10000`,只有确实需要调整 2,000 至 30,000 毫秒的建连超时时才在配置中修改。 +- 运行时 `ENCRYPTION_KEY`、`ACCESS_TEAM_DOMAIN`、`ACCESS_AUD` 的持久化来源是 **Cloudflare Worker Secrets**。不要保存为 Variable 或明文配置。 +- 新部署不需要 GitHub 写 Secrets 权限或额外 GitHub Token。生成的值不写入文件、命令行参数或 Actions artifact。 +- 重跑、推送新代码时保留原加密密钥。GitHub 中遗留的同名密钥不会替换 Worker 中的密钥。 +- **已有 D1 主机资料但缺少密钥时停止部署。** 必须恢复原密钥,不能生成新密钥假装修复。Cloudflare API 不提供 Secret 明文读回,自动生成的密钥也不会显示给用户;不要删除 Worker/Secret。需要独立灾备时,可在首次部署前自行生成并安全备份 32 字节 Base64 密钥,再保存为 `ENCRYPTION_KEY` GitHub Secret。 +- 旧部署已有三个 Worker Secrets 时,无需重新输入邮箱即可部署,也不要求复制 Secret 回 GitHub;脚本保留既有 Access 配置。这兼容人工管理的 Zone 级 Access 应用。 +- 若需要自动创建/管理 Access,或更换 hostname,请提供 `ADMIN_EMAIL`。自动管理使用账户级 Access API;原有 Zone 级应用请先核对,不要在同一 hostname 叠加应用。 +- 更换密钥、Zero Trust 组织或导致用户 `sub` 改变的身份迁移需要单独的数据迁移方案,不包含在普通 Deploy 中。 -不要使用普通密码、UUID、示例值或在线随机字符串网页作为 `ENCRYPTION_KEY`。密钥丢失将无法解密现有资料;当前版本不支持直接轮换,后续部署必须复用原值。 +## 登录方式扩展:GitHub OAuth -## 部署顺序 +认证与授权分开: -1. 确定 Worker、D1 名称与实际访问域名;自定义域名可选,默认使用 `workers.dev`。 -2. 为实际访问域名配置 Access 与最小权限 API 令牌(API Token),再将必需变量和机密保存到 GitHub Actions。 -3. 手动运行一次 `Deploy` 工作流(workflow),让 GitHub Actions 自动创建或复用 D1、迁移数据库、同步机密并创建或更新 Worker。 -4. 以后只需推送到 `main`;工作流会按相同名称复用远端资源并自动发布,不需要在本地执行 Wrangler 部署命令。 -5. 通过实际 Access 入口验收。所有账号、主机与连接 API 均需 Access 保护;缺少 Access 机密(Secret)时部署会在创建资源前失败。 +- **登录方式(IdP)**:默认 OTP;未来可用 Cloudflare Access 的 GitHub OAuth,仍由 Access 向 Worker 签发 JWT。 +- **授权策略(Policy)**:默认明确管理员邮箱。添加 GitHub 登录不会自动把整个 GitHub 组织或所有用户加入允许名单。 -密钥丢失将无法解密现有资料。更换密钥必须设计旧密钥解密、新密钥重加密的迁移,不可直接覆盖机密(Secret)。当前版本不提供自动轮换。更换 Access 团队或身份导致 `sub` 改变时,也需要显式的数据迁移。 +有现成 GitHub IdP 时,首次创建应用可通过 `ACCESS_IDP_IDS` 指定它;未设置时自动配置 OTP。多 IdP 新应用不启用单一 IdP 自动跳转。 -### GitHub Actions +已部署应用请在 Zero Trust 控制台添加 GitHub IdP、将其加入应用允许的登录方式,并在需要显示多种方式时关闭 instant authentication。后续 Deploy **不覆盖**这些设置,`ACCESS_IDP_IDS` 只控制新应用的默认配置。 -`.github/workflows/deploy.yml` 在推送到 `main` 或手动触发时依次执行安装、项目检查、远程 D1 迁移与 Worker 部署。生产部署使用并发锁串行执行,避免迁移和 Worker 版本交错。 +脚本只更新名为 `EdgeSSH administrator` 的自动邮箱策略。改用 GitHub 组织/团队等授权时,可将这条策略改名后自行管理;其他人工策略保持不变。管理员 GitHub 身份的邮箱仍需匹配邮箱策略。不要直接新增 Everyone、Bypass 或整域邮箱授权。 -仓库必须配置五个 Actions 值: +未来如果要实现不经过 Access 的原生 GitHub OAuth,则还需设计回调、会话与身份映射;本次没有混入第二套登录系统。 -| 名称 | 配置位置 | 示例值 | 要求 | -| --- | --- | --- | --- | -| `CLOUDFLARE_ACCOUNT_ID` | Actions 变量(Variable) | `0123456789abcdef0123456789abcdef` | 目标 Cloudflare 账户 ID | -| `CLOUDFLARE_API_TOKEN` | Actions 机密(Secret) | `Cloudflare 生成的令牌` | 限定到目标账户,并具备 Workers 部署与 D1 迁移权限 | -| `ACCESS_TEAM_DOMAIN` | Actions 机密(Secret) | `my-team.cloudflareaccess.com` | Zero Trust 团队域(Team Domain) | -| `ACCESS_AUD` | Actions 机密(Secret) | `012345…`(常见外观) | 实际访问域名对应 Access 应用的 AUD,必须原样复制实际值 | -| `ENCRYPTION_KEY` | Actions 机密(Secret) | `AbCd…=`(44 字符 Base64) | 首次生成后长期复用的随机加密密钥;不要使用示例文本 | +## 排障 -可选部署值及示例见上方配置表。`CUSTOM_DOMAIN` 可放 Variable 或 Secret;`WORKER_NAME`、`D1_DATABASE_NAME`、`D1_DATABASE_ID` 使用 Variable。三个运行时机密由工作流通过标准输入同步到 Worker;不要把本地 `.dev.vars` 或 `wrangler secret put` 作为普通用户的生产配置流程。 +- **403**:检查 Token 权限及账户/Zone 范围,不是重新生成加密密钥。 +- **找不到唯一账户**:限定 Token 到一个账户,或配置 `CLOUDFLARE_ACCOUNT_ID`。 +- **组织读取失败**:先完成 Zero Trust 开通和团队域设置。 +- **首次部署缺少邮箱**:在 Run workflow 输入,或设置 `ADMIN_EMAIL`。 +- **既有策略被人工修改**:脚本不会覆盖额外 require/exclude 等条件;在控制台维护,或改名后重跑。 +- **更换域名后无法登录**:带邮箱重新运行以配置新 hostname 的应用;不要仅改路由而沿用旧 AUD。 +- **OTP 未收到**:确认输入邮箱完全匹配 Allow 策略,检查垃圾邮件。GitHub 等其他 IdP 的账户邮箱同样必须匹配授权。 +- 手工配置、截图与 Access JWT 排查见 [Zero Trust 指南](docs/ZERO_TRUST.md)。 -## 验收清单(5.6 SOL 执行) +## 验收清单 -- `npm run typecheck`、`npm test`、`npm run build:web`、Wrangler dry-run。 -- Access:无 JWT、伪造 JWT、错误签名、错误 audience、错误 issuer、过期令牌均不能访问主机、票据和三种 WebSocket。 -- 两个不同 `sub` 的测试身份不能读取、修改、删除或解密彼此主机;DO 票据及辅助通道绑定相同账户。 -- 拒绝跨站 Origin;写操作缺少 Origin 也应拒绝;错误不泄露堆栈、凭据或数据库内容。 -- D1 新增/修改/删除、刷新后恢复、空密码语义、私钥保存、留空保留凭据、切换认证方式、指纹持久化。 -- AES-GCM 随机 IV、错误密钥/账户/主机 ID/篡改密文均不能解密,直接检查 D1 中不存在主机明文和凭据。 -- 定位失败不阻塞保存;私人地址不会被拿来发 HTTP 请求;城市标签与实际记录对应。 -- 地球点位和列表均可进入工作台;返回总览断开会话;工作台不显示/运行地球;刷新后主机仍存在。 -- 空列表、错误态、编辑/删除确认、搜索、分组、键盘操作;截图检查桌面及 320/375/414/768px,无水平溢出。 -- 用真实获授权的 SSH 测试目标验收握手、主机指纹、终端、SFTP 与进程面板。若未提供目标凭据,不得声称真实 SSH 验收通过。 -- 测试数据只用明确测试身份和测试主机,完成后清除自己创建的测试记录;不得清空生产表。 - -## 第一版限制 - -- 每个身份最多 200 台主机;无团队共享、后台密码登录、账号找回或旧浏览器记录自动导入。 -- 地球位置不等于在线状态,没有伪造的在线数、延迟和会话历史。 -- 同一城市的多个点可能接近,列表为完整可访问入口。 -- 首页为中文;原 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`;本次部署未读取或覆盖任何运行时机密 -- 无会话冒烟检查(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 与进程面板 - -## 访问域名 - -- 公共模板默认启用 `workers.dev`,不要求用户拥有自定义域名。 -- 当前项目实例另行设置了自定义正式入口 `https://ssh.dltwcnm.ccwu.cc`;这不是其他部署者的必需配置。 -- 无论使用 `workers.dev` 还是自定义域名,都必须为实际入口配置对应的 Access 应用。Worker 还会在应用层校验所有账号与连接 API 的 Access JWT。 +- `npm run check`:类型检查、测试、前端构建与部署 dry-run。 +- 空账户 bootstrap 与重复部署:只创建一次应用/OTP/D1,密钥不轮换,不覆盖 GitHub IdP。 +- 未登录访问入口应跳转 Access;未授权邮箱不可进入。 +- 登录后 `/api/auth/me` 与主机列表可用;无 JWT、伪造/过期 JWT 不可访问主机、票据或 WebSocket。 +- 加密资料跨部署保持可解密;测试不得清空生产 D1。 +- 真实 SSH、SFTP 与进程面板必须使用已获授权目标;未提供目标和登录会话时不声称验收完成。 diff --git a/README.md b/README.md index b5b5533..4a542cc 100644 --- a/README.md +++ b/README.md @@ -101,20 +101,30 @@ SSH 握手、密钥交换、认证与通道逻辑在 Worker 内完成。浏览 - Node.js **22.12.0 或更高版本**,以及 npm。 - 可使用 Workers、Durable Objects 与 D1 的 Cloudflare 账户。 -- Cloudflare Zero Trust Access 应用。自定义域名可选;默认可直接使用账户的 `workers.dev` 域名。 +- 已启用 Cloudflare Zero Trust 的账户。无需手动创建 Access 应用,自定义域名可选。 - 一台你有权访问的公网 SSH 服务器。 -### 配置 Cloudflare Zero Trust Access +### 一次运行,自动配置认证与部署 -EdgeSSH 没有独立的本地登录系统,正式入口必须先经过 Cloudflare Zero Trust Access。首次部署时请先完成: +你只需准备三项: -1. 在 **Zero Trust > 访问控制(Access controls)> 应用程序(Applications)** 创建 **自托管和私有应用(Self-hosted and private)**,并绑定 EdgeSSH 的实际访问域名。默认可填写 `.<你的 Workers 子域>.workers.dev`,同时将 `CUSTOM_DOMAIN` 留空;只有真正绑定自定义域名时才填写 `CUSTOM_DOMAIN`。 -2. 添加一条 **允许(Allow)** 策略,使用 **包括(Include)> 电子邮件(Emails)** 明确填写管理员邮箱;不要使用 **所有人(Everyone)** 或 **绕过(Bypass)**。 -3. 选择 **标识提供程序(Identity Provider)**。个人部署可使用 **一次性 PIN(One-time PIN)**;新建 Zero Trust 组织若没有该选项,需要先到 **集成(Integrations)> 标识提供程序(Identity providers)** 手动添加。 -4. 从 Access 应用中取得 **应用受众 (AUD) 标签(Application Audience (AUD) Tag)**,并从 Zero Trust **设置(Settings)** 取得 **团队域(Team Domain)**。 -5. 将它们分别写入 `ACCESS_AUD` 与 `ACCESS_TEAM_DOMAIN` Worker 机密(Secret)。 +1. **启用 Cloudflare Zero Trust**,完成团队域名和账户计划的初始化。 +2. 在 Fork 后仓库的 **Settings > Secrets and variables > Actions** 添加 Secret:`CLOUDFLARE_API_TOKEN`。权限清单见[部署指南](DEPLOYMENT.md#api-token-权限)。 +3. 打开 **Actions > Deploy > Run workflow**,填写管理员邮箱并运行。也可预先将邮箱保存为 `ADMIN_EMAIL` Variable。 -完整的控制台点击步骤、OTP 配置、参数获取和故障排查见 **[Cloudflare Zero Trust 配置指南](docs/ZERO_TRUST.md)**。 +```text +Zero Trust 已启用 + Cloudflare API Token + 管理员邮箱 + ↓ Run workflow +发现账户与 workers.dev 子域 → 创建/复用 Access 应用、邮箱 Allow 策略与 OTP + → 获取 Team Domain 与 AUD → 创建/复用 D1 → 首次生成加密密钥 + → 数据库迁移 → 写入 Worker Secrets → 部署 Worker + ↓ +在 Actions 运行摘要打开访问地址,用邮箱验证码登录 +``` + +无需手填 Account ID、D1 ID、Team Domain、AUD 或随机密钥。Token 能访问多个账户时,才需要用 `CLOUDFLARE_ACCOUNT_ID` 选择目标账户。 + +EdgeSSH 面向**单管理员**,不允许匿名访问。重复运行复用资源和加密密钥,不删除数据;已有 Access 应用的登录方式及人工策略不会被覆盖。以后可在 Access 添加 GitHub 等身份提供程序,无需修改 Worker 的 JWT 校验逻辑。 ### 获取项目 @@ -124,54 +134,23 @@ cd EdgeSSH npm ci ``` -### 配置与部署 +### 可选配置 -> [!IMPORTANT] -> 本项目面向**单管理员**使用。Access 策略应仅允许管理员的明确身份,不要配置**所有人(Everyone)**或**绕过(Bypass)**。缺少 Access 配置时,受保护 API 会拒绝访问,不会降级为匿名网关。 +以下通常都可以留空,保存为 Actions Variable: -1. 确定实际访问域名。默认入口为 `https://edgessh.<你的 Workers 子域>.workers.dev`;若通过 `WORKER_NAME` 修改 Worker 名称,入口中的 `edgessh` 也随之改变。自定义域名不是必需项。 -2. 在 Zero Trust 控制台为该实际访问域名创建**自托管和私有应用(Self-hosted and private)**,只允许管理员的明确邮箱或身份,并记下**团队域(Team Domain)**与**应用受众 (AUD) 标签(Application Audience (AUD) Tag)**。 -3. 在 GitHub Actions 中配置部署变量与机密(Secret),首次运行 `Deploy` 工作流(workflow)。工作流会在目标账户中按名称复用或创建 D1、应用迁移、同步 Worker 机密,并创建或更新 Worker。 -4. 部署完成后打开实际 Access 入口验收。以后重复运行会复用同名 Worker 与 D1,不需要手工复制 `database_id` 或修改 `wrangler.toml`。 +| 名称 | 示例值 | 用途 | +| --- | --- | --- | +| `ADMIN_EMAIL` | `you@example.com` | 省去每次手动输入邮箱;修改后更新脚本创建的邮箱策略 | +| `CLOUDFLARE_ACCOUNT_ID` | `0123456789abcdef0123456789abcdef` | 多账户 Token 的账户选择 | +| `WORKER_NAME` | `my-edgessh` | 默认 `edgessh` | +| `D1_DATABASE_NAME` | `my-edgessh-accounts` | 默认 `-accounts` | +| `D1_DATABASE_ID` | `00000000-0000-4000-8000-000000000001` | 复用明确指定的数据库 | +| `CUSTOM_DOMAIN` | `ssh.example.com` | 真正的自定义域名;`*.workers.dev` 必须留空;兼容 Secret,Secret 优先 | +| `ACCESS_IDP_IDS` | `一个或多个 IdP UUID,以逗号分隔` | 创建新应用时使用已有 GitHub/其他 IdP,而非自动配置 OTP | -| 配置 | 类型与位置 | 示例值 | 用途 | -| --- | --- | --- | --- | -| `DB` | `wrangler.toml` 中的 D1 binding | `DB` | 保存加密主机资料 | -| `SSH_SESSIONS` | `wrangler.toml` 中的 Durable Object binding | `SSH_SESSIONS` | 隔离 SSH 会话 | -| `ASSETS` | `wrangler.toml` 中的静态资源 binding | `ASSETS` | 提供前端资源 | -| `ACCESS_TEAM_DOMAIN` | GitHub Actions 机密(Secret) | `my-team.cloudflareaccess.com` | Access 团队域名,部署时同步为 Worker 机密 | -| `ACCESS_AUD` | GitHub Actions 机密(Secret) | `012345…`(常见外观) | Access 应用的应用受众 (AUD) 标签(Application Audience (AUD) Tag),必须原样复制实际值,部署时同步为 Worker 机密 | -| `ENCRYPTION_KEY` | GitHub Actions 机密(Secret) | `AbCd…=`(44 字符 Base64) | 32 字节安全随机密钥的标准 Base64,部署时同步为 Worker 机密;不要使用示例文本 | -| `CONNECT_TIMEOUT_MS` | 可选普通变量 | `10000` | TCP 建连超时;仓库已有默认值,通常无需填写 | +`ACCESS_TEAM_DOMAIN`、`ACCESS_AUD`、`ENCRYPTION_KEY` 由部署流程管理并持久保存在 **Cloudflare Worker Secrets**,不需要用户复制回 GitHub。加密密钥只在首次部署生成,后续保留,即使 GitHub 留有旧值也不会覆盖线上密钥。不要删除 Worker 或其加密密钥;Cloudflare 不提供密钥明文读回,丢失后无法解密已有资料。 -生产运行时的 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD` 与 `ENCRYPTION_KEY` 统一保存在 GitHub Actions 机密(Secret)中,由工作流通过标准输入同步为 Worker 机密,不写入仓库或普通变量。`DB`、`SSH_SESSIONS` 与 `ASSETS` 是部署时创建的绑定,不是环境变量;`CONNECT_TIMEOUT_MS` 已有默认配置,也无需重复添加。 - -**首次部署才生成 `ENCRYPTION_KEY`,后续部署始终复用原值。** 丢失或直接替换密钥会导致已有资料无法解密。密钥只保存为 GitHub Actions 机密(Secret),不要写入源码、`wrangler.toml`、普通变量或提交记录。 - -完整配置顺序、安全边界与验收清单见 [部署指南](DEPLOYMENT.md)。 - -### GitHub Actions 自动部署 - -仓库内置的 `Deploy` 工作流(workflow)会在推送到 `main` 后自动检查、迁移 D1 并部署,也可在 Actions 页面手动触发。首次使用前,在仓库的**设置(Settings)> 机密和变量(Secrets and variables)> Actions**中配置: - -| 名称 | GitHub 配置类型 | 示例值 | 用途 | -| --- | --- | --- | --- | -| `CLOUDFLARE_ACCOUNT_ID` | 变量(Variable) | `0123456789abcdef0123456789abcdef` | Cloudflare 账户 ID | -| `CLOUDFLARE_API_TOKEN` | 机密(Secret) | `Cloudflare 生成的令牌` | 具备 Workers 部署和 D1 迁移权限的 API 令牌(API Token) | -| `ACCESS_TEAM_DOMAIN` | 机密(Secret) | `my-team.cloudflareaccess.com` | Zero Trust 团队域(Team Domain) | -| `ACCESS_AUD` | 机密(Secret) | `012345…`(常见外观) | 实际访问域名对应 Access 应用的 AUD,必须原样复制实际值 | -| `ENCRYPTION_KEY` | 机密(Secret) | `AbCd…=`(44 字符 Base64) | 首次生成后长期复用的随机密钥;不要使用示例文本 | - -| 可选名称 | GitHub 配置类型 | 示例值 | 用途 | -| --- | --- | --- | --- | -| `WORKER_NAME` | 变量(Variable) | `my-edgessh` | 覆盖默认 Worker 名 `edgessh` | -| `D1_DATABASE_NAME` | 变量(Variable) | `my-edgessh-accounts` | 指定 D1 名称 | -| `D1_DATABASE_ID` | 变量(Variable) | `00000000-0000-4000-8000-000000000001` | 复用一个已知 D1 数据库 | -| `CUSTOM_DOMAIN` | 变量(Variable)或机密(Secret) | `ssh.example.com` | 仅用于真正的自定义域名;使用 `*.workers.dev` 时必须留空 | - -全部可选值省略时,工作流使用 `edgessh`、自动创建或复用 `edgessh-accounts`,并发布到该账户的 `workers.dev` 域名。`CUSTOM_DOMAIN` 推荐使用 Variable,但兼容 Secret;两处同时设置时 Secret 优先。 - -创建 API 令牌(API Token)时,可使用 Cloudflare 的**编辑 Cloudflare Workers(Edit Cloudflare Workers)**模板并补充 `D1: Edit`。只使用 `workers.dev` 时不需要目标区域(Zone)的自定义域名权限;设置 `CUSTOM_DOMAIN` 时,再将区域资源限制到实际使用的域名。API 令牌和运行时机密都必须保存为 GitHub 机密(Secret),不能保存为普通变量(Variable)。 +推送 `main` 或手动运行 `Deploy` 都会执行检查和部署。只在首次 Run workflow 输入邮箱也可以:后续未提供邮箱时保留已有认证配置。需要更换域名或重新自动配置 Access 时,请再次提供邮箱。完整权限、旧版迁移及 GitHub 登录扩展说明见[部署指南](DEPLOYMENT.md);手工维护见 [Zero Trust 指南](docs/ZERO_TRUST.md)。 ## 本地开发 diff --git a/docs/ZERO_TRUST.md b/docs/ZERO_TRUST.md index 6d07e1f..1e269f0 100644 --- a/docs/ZERO_TRUST.md +++ b/docs/ZERO_TRUST.md @@ -1,5 +1,9 @@ # Cloudflare Zero Trust Access 配置 +> **普通部署无需手工执行本页步骤。** 启用 Zero Trust 后,保存 Cloudflare API Token,在 **Actions > Deploy > Run workflow** 输入管理员邮箱,工作流会自动创建/复用 Access 应用、邮箱 Allow 策略、OTP,并获取团队域与 AUD、保存 Worker Secrets。详见[自动部署指南](../DEPLOYMENT.md)。 +> +> 本页保留控制台操作,供排障、维护既有应用或扩展 GitHub 等登录方式使用。部署脚本不会覆盖已有应用的 IdP 配置。 + EdgeSSH **不提供本地用户名 / 密码登录**。为了避免任何人直接打开你的 WebSSH,生产环境必须把访问入口放在 Cloudflare Zero Trust Access 后面。 EdgeSSH 会在 Worker 内再次校验 Cloudflare Access 注入的 `Cf-Access-Jwt-Assertion`,包括签名、Issuer、Audience、有效期、用户 `sub` 和邮箱。因此,仅仅给域名加一个普通登录页还不够:**Access 应用、身份策略、`ACCESS_TEAM_DOMAIN` 和 `ACCESS_AUD` 必须对应同一个应用。** @@ -115,9 +119,9 @@ my-team.cloudflareaccess.com Cloudflare 官方获取 AUD 的说明: https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/ -## 在 GitHub Actions 保存 Worker 机密(Secret) +## Worker 机密(Secret)的自动管理 -按 [部署指南](../DEPLOYMENT.md) 打开 GitHub 仓库的**设置(Settings)> 机密和变量(Secrets and variables)> Actions**,将以下值添加为仓库机密(Repository secrets): +自动部署会取得并保存以下值到 Cloudflare Worker Secrets,无需用户复制回 GitHub: | 名称 | 示例值 | 说明 | | --- | --- | --- | @@ -125,7 +129,7 @@ https://developers.cloudflare.com/cloudflare-one/access-controls/applications/ht | `ACCESS_AUD` | `012345…`(常见外观) | 前面取得的应用受众 (AUD) 标签(Application Audience (AUD) Tag),必须原样复制实际值 | | `ENCRYPTION_KEY` | `AbCd…=`(44 字符 Base64) | 32 字节安全随机数的标准 Base64,只在首次部署时生成,后续始终复用;不要使用示例文本 | -`Deploy` 工作流会先校验这三个值,再通过标准输入同步为 Cloudflare Worker 机密(Secret)。它们不会写入 `wrangler.toml`、临时文件、普通变量或提交记录。 +`Deploy` 通过标准输入写入新机密,后续保留 Worker 中的加密密钥。它们不会写入 `wrangler.toml`、临时文件、普通变量或提交记录。旧部署已有 Access 机密且未输入管理员邮箱时,也会保留原 Access 配置。 `ENCRYPTION_KEY` 的生成要求和保管注意事项见[部署指南](../DEPLOYMENT.md#worker-runtime-secrets)。生产部署不需要在本地执行 Wrangler 机密(Secret)或 Worker 部署命令。 @@ -145,9 +149,9 @@ https://developers.cloudflare.com/cloudflare-one/access-controls/applications/ht 检查: -- GitHub Actions 中的 `ACCESS_TEAM_DOMAIN` 是否配置正确,以及最近一次 `Deploy` 工作流是否已成功将它同步到当前 Worker 版本。普通部署无需在 Worker 控制台手工重复保存。 +- Worker 中是否已有 `ACCESS_TEAM_DOMAIN` Secret,以及最近一次 `Deploy` 是否成功。首次部署请提供管理员邮箱,由工作流自动获取并保存。 - 团队域(Team Domain)是否为 `xxx.cloudflareaccess.com`,且没有 `https://`。 -- GitHub Actions 中的 `ACCESS_AUD` 是否来自当前 Access 应用,以及最近一次 `Deploy` 工作流是否同步成功。 +- Worker 中是否已有 `ACCESS_AUD` Secret,并对应当前入口的 Access 应用。更换 hostname 后需要带管理员邮箱重新运行部署。 ### 登录后提示“Access 登录已失效” diff --git a/scripts/cloudflare-access.ts b/scripts/cloudflare-access.ts new file mode 100644 index 0000000..3c1bcf4 --- /dev/null +++ b/scripts/cloudflare-access.ts @@ -0,0 +1,117 @@ +import { CloudflareApi, CloudflareApiError } from './cloudflare-api.ts'; +import type { DeploymentSettings } from './deployment-config.ts'; + +interface AccessApplication { + id: string; + aud: string; + type: string; + domain: string; + self_hosted_domains?: string[]; +} + +interface IdentityProvider { id: string; type: string } +interface AccessPolicy { + id: string; + name: string; + decision: string; + include: unknown[]; + exclude?: unknown[]; + require?: unknown[]; + precedence?: number; +} + +export async function resolveWorkerHostname(api: CloudflareApi, settings: DeploymentSettings): Promise { + const path = `/accounts/${settings.accountId}/workers/subdomain`; + let subdomain: string | null = null; + try { + ({ subdomain } = await api.request<{ subdomain: string | null }>(path)); + } catch (error) { + // 与 Wrangler 一样,只将 10007 识别为“尚未注册”;权限/网络失败不能触发资源创建。 + if (!(error instanceof CloudflareApiError) || !error.codes.includes(10007)) throw error; + } + if (!subdomain) { + // 只有账户尚未注册子域时才初始化,避免改动同账户其他 Worker 的地址。 + subdomain = `${settings.workerName}-${settings.accountId.slice(0, 8)}`; + await api.request(path, 'PUT', { subdomain }); + } + return `${settings.workerName}.${subdomain}.workers.dev`; +} + +export async function ensureAccess( + api: CloudflareApi, + settings: DeploymentSettings, + hostname: string, +): Promise<{ ACCESS_TEAM_DOMAIN: string; ACCESS_AUD: string }> { + const base = `/accounts/${settings.accountId}/access`; + const organization = await api.request<{ auth_domain: string }>(`${base}/organizations`); + if (!/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(organization.auth_domain ?? '')) { + throw new Error('请先在 Cloudflare 启用 Zero Trust 并设置团队域名,再运行 Deploy。'); + } + const apps = await api.list(`${base}/apps`); + const matches = apps.filter((app) => app.domain === hostname || app.self_hosted_domains?.includes(hostname)); + if (matches.length > 1) throw new Error('同一入口匹配多个 Access 应用,请先在 Zero Trust 控制台消除冲突。'); + let app = matches[0]; + const policyName = `EdgeSSH ${settings.workerName} administrator`; + const policy = { + name: policyName, + decision: 'allow', + include: [{ email: { email: settings.adminEmail } }], + }; + + if (!app) { + if (!settings.adminEmail) throw new Error('首次创建 Access 应用需要 ADMIN_EMAIL,请在 Run workflow 输入管理员邮箱。'); + const providers = await api.list(`${base}/identity_providers`); + let providerIds = settings.identityProviderIds; + if (providerIds.length) { + if (providerIds.some((id) => !providers.some((provider) => provider.id === id))) { + throw new Error('ACCESS_IDP_IDS 包含不属于当前账户的身份提供程序。'); + } + } else { + let otp = providers.find((provider) => provider.type === 'onetimepin'); + if (!otp) { + otp = await api.request(`${base}/identity_providers`, 'POST', { + name: 'Email PIN', type: 'onetimepin', config: {}, + }); + } + providerIds = [otp.id]; + } + // 登录方式仅是应用创建时的默认值;以后重跑不覆盖控制台添加的 GitHub 等 IdP。 + // 应用和内嵌 Allow 策略一起创建,避免产生可访问但没有授权规则的中间状态。 + app = await api.request(`${base}/apps`, 'POST', { + name: `EdgeSSH ${settings.workerName}`, + type: 'self_hosted', + domain: hostname, + session_duration: '24h', + allowed_idps: providerIds, + auto_redirect_to_identity: providerIds.length === 1, + policies: [{ ...policy, precedence: 1 }], + }); + } else { + if (app.type !== 'self_hosted') throw new Error('现有入口不是自托管 Access 应用,停止部署以免覆盖其他用途的配置。'); + const policies = await api.list(`${base}/apps/${app.id}/policies`); + if (!policies.length) throw new Error('现有 Access 应用没有策略,请先添加明确身份的 Allow 策略。'); + if (policies.some((entry) => entry.decision === 'bypass' + || (entry.decision === 'allow' && entry.include.some((rule) => { + const selector = rule as Record; + return 'everyone' in selector || 'email_domain' in selector; + })))) { + throw new Error('现有 Access 策略包含 Bypass、Everyone 或整域邮箱授权,请先收紧为明确身份。'); + } + const managed = policies.find((entry) => entry.name === policyName); + // 只修改脚本拥有的邮箱策略,既有应用的 IdP 和人工策略都不接管。 + if (managed && settings.adminEmail) { + if (managed.decision !== 'allow' || managed.exclude?.length || managed.require?.length + || managed.include.length !== 1 || !('email' in (managed.include[0] as object))) { + throw new Error('自动邮箱策略已被人工修改,请在控制台管理它,或将其改名后重跑以保留人工配置。'); + } + const email = (managed.include[0] as { email: { email: string } }).email.email; + if (email !== settings.adminEmail) { + await api.request(`${base}/apps/${app.id}/policies/${managed.id}`, 'PUT', { + ...policy, precedence: managed.precedence, + }); + } + } + } + if (!app.aud) throw new Error('Access 应用没有返回 AUD,停止部署。'); + return { ACCESS_TEAM_DOMAIN: organization.auth_domain, ACCESS_AUD: app.aud }; +} diff --git a/scripts/cloudflare-api.ts b/scripts/cloudflare-api.ts new file mode 100644 index 0000000..179dcaf --- /dev/null +++ b/scripts/cloudflare-api.ts @@ -0,0 +1,71 @@ +const apiUrl = 'https://api.cloudflare.com/client/v4'; + +interface ApiResult { + success: boolean; + result: T; + errors?: { code: number }[]; + result_info?: { total_pages?: number }; +} + +export class CloudflareApiError extends Error { + readonly codes: number[]; + + constructor(message: string, codes: number[]) { + super(message); + this.codes = codes; + } +} + +export class CloudflareApi { + private readonly token: string; + private readonly fetcher: typeof fetch; + + constructor(token: string, fetcher: typeof fetch = fetch) { + this.token = token; + this.fetcher = fetcher; + } + + private async send(path: string, method: string, body?: unknown): Promise> { + const response = await this.fetcher(`${apiUrl}${path}`, { + method, + headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: AbortSignal.timeout(30_000), + }); + // 不输出响应体和请求参数,避免错误日志泄露邮箱、密钥及账户内部信息。 + if (!response.ok) { + const error = await response.json().catch(() => ({})) as { errors?: { code: number }[] }; + throw new CloudflareApiError( + `Cloudflare ${method} ${path.split('?')[0]} 失败(HTTP ${response.status})。请核对 Token 权限与资源范围。`, + error.errors?.map((entry) => entry.code) ?? [], + ); + } + const payload = await response.json() as ApiResult; + if (!payload.success) throw new Error(`Cloudflare ${method} 请求未成功,请检查权限及资源配置。`); + return payload; + } + + async request(path: string, method = 'GET', body?: unknown): Promise { + return (await this.send(path, method, body)).result; + } + + async list(path: string): Promise { + const items: T[] = []; + for (let page = 1; ; page++) { + const payload = await this.send(`${path}?per_page=50&page=${page}`, 'GET'); + items.push(...payload.result); + if (payload.result_info?.total_pages !== undefined + ? page >= payload.result_info.total_pages + : payload.result.length < 50) return items; + } + } +} + +export async function resolveAccountId(api: CloudflareApi, configured: string): Promise { + if (configured) return configured; + const accounts = await api.list<{ id: string }>('/accounts'); + if (accounts.length !== 1) { + throw new Error('无法唯一确定 Cloudflare 账户。请将 Token 限定到一个账户,或设置 CLOUDFLARE_ACCOUNT_ID。'); + } + return accounts[0].id; +} diff --git a/scripts/deploy.ts b/scripts/deploy.ts index bbcf386..e9eb710 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -1,9 +1,12 @@ import { spawn } from 'node:child_process'; -import { readFile, writeFile } from 'node:fs/promises'; +import { appendFile, readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { parse, stringify } from 'smol-toml'; import { ensureDatabase } from './cloudflare-d1.ts'; import { createDeploymentConfig, readDeploymentSettings } from './deployment-config.ts'; +import { CloudflareApi, resolveAccountId } from './cloudflare-api.ts'; +import { ensureAccess, resolveWorkerHostname } from './cloudflare-access.ts'; +import { maskSecrets, prepareEncryptionSecret, readWorkerSecretNames } from './deployment-secrets.ts'; const root = fileURLToPath(new URL('../', import.meta.url)); const generatedConfig = '.wrangler.generated.toml'; @@ -33,15 +36,41 @@ async function main(): Promise { return; } + const api = new CloudflareApi(settings.apiToken); + settings.accountId = await resolveAccountId(api, settings.accountId); + process.env.CLOUDFLARE_ACCOUNT_ID = settings.accountId; + const existingSecrets = await readWorkerSecretNames(api, settings); + const hostname = settings.customDomain || await resolveWorkerHostname(api, settings); + // 旧部署可能由 Zone 级 Access 管理。未提供邮箱且已有两个 Access Secret 时保留原认证, + // 不擅自新建账户级应用覆盖它;新用户仍只需输入邮箱即可走完整自动配置。 + const preserveAccess = !settings.adminEmail + && existingSecrets.has('ACCESS_TEAM_DOMAIN') && existingSecrets.has('ACCESS_AUD'); + if (!preserveAccess && !settings.adminEmail) { + throw new Error('首次部署需要 ADMIN_EMAIL,请在 Run workflow 输入管理员邮箱,或保存为 Actions 变量。'); + } + const accessSecrets = preserveAccess ? {} : await ensureAccess(api, settings, hostname); + console.log(preserveAccess ? '保留已有 Access 认证配置。' : 'Access 应用、身份提供程序与邮箱策略已就绪。'); const database = await ensureDatabase(settings); + const secrets = { ...accessSecrets, ...await prepareEncryptionSecret(api, settings, database, existingSecrets) }; + maskSecrets(secrets); const config = createDeploymentConfig(template, settings, database); // 临时配置放在仓库根目录,保持 assets、main、migrations_dir 的相对路径语义。 await writeFile(new URL(`../${generatedConfig}`, import.meta.url), stringify(config), 'utf8'); console.log(`部署 Worker ${settings.workerName},使用 D1 ${database.name}(${database.uuid})。`); await runWrangler(['d1', 'migrations', 'apply', 'DB', '--remote']); // Secret 只通过标准输入发送,不写临时文件或命令行参数;首次部署由 Wrangler 创建草稿 Worker。 - await runWrangler(['secret', 'bulk'], JSON.stringify(settings.secrets)); + if (Object.keys(secrets).length) await runWrangler(['secret', 'bulk'], JSON.stringify(secrets)); await runWrangler(['deploy']); + // Wrangler 默认保留既有 Secret;部署后再次核对名称,避免把缺少认证的版本当作成功。 + const deployedSecrets = await readWorkerSecretNames(api, settings); + for (const name of ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']) { + if (!deployedSecrets.has(name)) throw new Error(`部署后缺少 Worker Secret:${name}。`); + } + console.log(`部署完成:https://${hostname}`); + if (process.env.GITHUB_STEP_SUMMARY) { + await appendFile(process.env.GITHUB_STEP_SUMMARY, + `## EdgeSSH 部署完成\n\n入口:https://${hostname}\n\nD1 已迁移,运行时 Secret 已保存在 Cloudflare。后续部署保留原加密密钥。\n`); + } } main().catch((error: Error) => { diff --git a/scripts/deployment-config.ts b/scripts/deployment-config.ts index 64972b2..0d46eae 100644 --- a/scripts/deployment-config.ts +++ b/scripts/deployment-config.ts @@ -10,7 +10,9 @@ export interface DeploymentSettings { databaseName: string; databaseId?: string; customDomain?: string; - secrets: RuntimeSecrets; + adminEmail?: string; + identityProviderIds: string[]; + secrets: Partial; } export interface Database { @@ -22,12 +24,12 @@ export function readDeploymentSettings( template: TomlTable, env: NodeJS.ProcessEnv, ): DeploymentSettings { - const required = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN', ...runtimeSecretNames]; + const required = ['CLOUDFLARE_API_TOKEN']; const missing = required.filter((name) => !env[name]?.trim()); if (missing.length) throw new Error(`缺少部署配置:${missing.join(', ')}。请在 GitHub Actions 中配置。`); - const accountId = env.CLOUDFLARE_ACCOUNT_ID!.trim(); - if (!/^[a-f0-9]{32}$/i.test(accountId)) throw new Error('CLOUDFLARE_ACCOUNT_ID 必须是 32 位十六进制账户 ID。'); + const accountId = env.CLOUDFLARE_ACCOUNT_ID?.trim() || ''; + if (accountId && !/^[a-f0-9]{32}$/i.test(accountId)) throw new Error('CLOUDFLARE_ACCOUNT_ID 必须是 32 位十六进制账户 ID。'); const workerName = env.WORKER_NAME?.trim() || String(template.name); if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(workerName)) throw new Error('WORKER_NAME 必须是 1–63 位小写字母、数字或连字符,且不能以连字符开头。'); @@ -50,23 +52,35 @@ export function readDeploymentSettings( throw new Error('CUSTOM_DOMAIN 不能填写 workers.dev 地址;使用 Worker 自带域名时请删除或留空该配置。'); } - const secrets = Object.fromEntries(runtimeSecretNames.map((name) => [name, env[name]!.trim()])) as RuntimeSecrets; - const key = Buffer.from(secrets.ENCRYPTION_KEY, 'base64'); - if (!/^[A-Za-z0-9+/]{43}=?$/.test(secrets.ENCRYPTION_KEY) - || key.length !== 32 - || key.toString('base64').replace(/=+$/, '') !== secrets.ENCRYPTION_KEY.replace(/=+$/, '')) { - throw new Error('ENCRYPTION_KEY 必须是 Base64 编码的 32 字节密钥;已有数据时必须继续使用原密钥。'); + const adminEmail = env.ADMIN_EMAIL?.trim().toLowerCase(); + if (adminEmail && !/^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/.test(adminEmail)) { + throw new Error('ADMIN_EMAIL 必须是一个完整的管理员邮箱,不能填写多个邮箱或整个域名。'); } - if (!/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(secrets.ACCESS_TEAM_DOMAIN)) { + const identityProviderIds = [...new Set((env.ACCESS_IDP_IDS || '').split(',').map((id) => id.trim()).filter(Boolean))]; + if (identityProviderIds.some((id) => !/^[a-f0-9-]{36}$/i.test(id))) { + throw new Error('ACCESS_IDP_IDS 必须是逗号分隔的 Cloudflare 身份提供程序 UUID。'); + } + const secrets = Object.fromEntries(runtimeSecretNames + .filter((name) => env[name]?.trim()) + .map((name) => [name, env[name]!.trim()])) as Partial; + if (secrets.ENCRYPTION_KEY) { + const key = Buffer.from(secrets.ENCRYPTION_KEY, 'base64'); + if (!/^[A-Za-z0-9+/]{43}=?$/.test(secrets.ENCRYPTION_KEY) + || key.length !== 32 + || key.toString('base64').replace(/=+$/, '') !== secrets.ENCRYPTION_KEY.replace(/=+$/, '')) { + throw new Error('ENCRYPTION_KEY 必须是 Base64 编码的 32 字节密钥;已有数据时必须继续使用原密钥。'); + } + } + if (secrets.ACCESS_TEAM_DOMAIN && !/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(secrets.ACCESS_TEAM_DOMAIN)) { throw new Error('ACCESS_TEAM_DOMAIN 必须形如 team.cloudflareaccess.com,不包含 https://。'); } if (runtimeSecretNames.some((name) => name in ((template.vars as TomlTable | undefined) ?? {}))) { - throw new Error('运行时 Secret 不得放在 wrangler.toml 的 vars 中,请改用 GitHub Actions Secrets。'); + throw new Error('运行时 Secret 不得放在 wrangler.toml 的 vars 中,请使用 Worker Secrets。'); } return { accountId, apiToken: env.CLOUDFLARE_API_TOKEN!.trim(), workerName, - databaseName, databaseId, customDomain, secrets, + databaseName, databaseId, customDomain, adminEmail, identityProviderIds, secrets, }; } @@ -79,6 +93,9 @@ export function createDeploymentConfig( ...template, account_id: settings.accountId, name: settings.workerName, + // 自定义入口启用时关闭备用公网入口,避免绕过对应的 Access 登录页。 + workers_dev: !settings.customDomain, + preview_urls: false, ...(settings.customDomain ? { routes: [{ pattern: settings.customDomain, custom_domain: true }] } : {}), d1_databases: (template.d1_databases as TomlTable[]).map((binding) => binding.binding === 'DB' ? { ...binding, database_name: database.name, database_id: database.uuid } diff --git a/scripts/deployment-secrets.ts b/scripts/deployment-secrets.ts new file mode 100644 index 0000000..971461f --- /dev/null +++ b/scripts/deployment-secrets.ts @@ -0,0 +1,44 @@ +import { randomBytes } from 'node:crypto'; +import { CloudflareApi } from './cloudflare-api.ts'; +import type { Database, DeploymentSettings, RuntimeSecrets } from './deployment-config.ts'; + +export async function readWorkerSecretNames(api: CloudflareApi, settings: DeploymentSettings): Promise> { + const base = `/accounts/${settings.accountId}/workers/scripts`; + // 明确查找 Worker,而不是把 403/404 一律当成“首次部署”,避免误生成新密钥。 + const workers = await api.request<{ id: string }[]>(base); + if (!workers.some((worker) => worker.id === settings.workerName)) return new Set(); + const secrets = await api.request<{ name: string }[]>(`${base}/${settings.workerName}/secrets`); + return new Set(secrets.map((secret) => secret.name)); +} + +export async function prepareEncryptionSecret( + api: CloudflareApi, + settings: DeploymentSettings, + database: Database, + existing: Set, +): Promise> { + // Cloudflare 中的原密钥始终优先,不允许旧 Actions Secret 意外覆盖线上密钥。 + if (existing.has('ENCRYPTION_KEY')) return {}; + if (settings.secrets.ENCRYPTION_KEY) return { ENCRYPTION_KEY: settings.secrets.ENCRYPTION_KEY }; + const path = `/accounts/${settings.accountId}/d1/database/${database.uuid}/query`; + const tables = await api.request<{ results: { name: string }[]; success: boolean }[]>(path, 'POST', { + sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'hosts'", + }); + if (tables.some((query) => !query.success)) throw new Error('无法确认 D1 状态,停止生成密钥。'); + if (tables[0].results.length) { + const rows = await api.request<{ results: unknown[]; success: boolean }[]>(path, 'POST', { + sql: 'SELECT 1 FROM hosts LIMIT 1', + }); + if (rows.some((query) => !query.success)) throw new Error('无法确认 D1 状态,停止生成密钥。'); + if (rows[0].results.length) { + throw new Error('D1 已有加密资料但 Worker 缺少 ENCRYPTION_KEY。请恢复原密钥,不能生成替代密钥。'); + } + } + return { ENCRYPTION_KEY: randomBytes(32).toString('base64') }; +} + +export function maskSecrets(secrets: Partial): void { + if (process.env.GITHUB_ACTIONS === 'true') { + for (const value of Object.values(secrets)) console.log(`::add-mask::${value}`); + } +} diff --git a/tests/deployment-bootstrap.test.ts b/tests/deployment-bootstrap.test.ts new file mode 100644 index 0000000..40bdb3c --- /dev/null +++ b/tests/deployment-bootstrap.test.ts @@ -0,0 +1,227 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { CloudflareApi, resolveAccountId } from '../scripts/cloudflare-api.ts'; +import { ensureAccess, resolveWorkerHostname } from '../scripts/cloudflare-access.ts'; +import { prepareEncryptionSecret, readWorkerSecretNames } from '../scripts/deployment-secrets.ts'; +import type { DeploymentSettings } from '../scripts/deployment-config.ts'; + +const settings: DeploymentSettings = { + accountId: 'a'.repeat(32), apiToken: 'test-token', workerName: 'edgessh', + databaseName: 'edgessh-accounts', adminEmail: 'admin@example.com', + identityProviderIds: [], secrets: {}, +}; +const database = { uuid: 'test-database', name: settings.databaseName }; +const json = (result: unknown) => Response.json({ success: true, result }); + +test('account discovery accepts exactly one account; explicit selection avoids list permission', async () => { + for (const count of [0, 1, 2]) { + const api = new CloudflareApi('token', async () => json(Array.from({ length: count }, () => ({ id: settings.accountId })))); + if (count === 1) assert.equal(await resolveAccountId(api, ''), settings.accountId); + else await assert.rejects(resolveAccountId(api, ''), /唯一确定/); + } + assert.equal(await resolveAccountId(new CloudflareApi('token', async () => { throw Error('unexpected'); }), 'selected'), 'selected'); +}); + +test('API pagination uses metadata and errors never echo upstream response details', async () => { + let pages = 0; + const api = new CloudflareApi('token', async () => { + pages++; + return Response.json({ success: true, result: [pages], result_info: { total_pages: 2 } }); + }); + assert.deepEqual(await api.list('/accounts'), [1, 2]); + await new CloudflareApi('token', async (url) => { + assert.equal(new URL(String(url)).searchParams.get('per_page'), '50'); + return json([]); + }).list('/accounts'); + const denied = new CloudflareApi('token', async () => new Response('private-token-response', { status: 403 })); + await assert.rejects(denied.list('/accounts'), (error: Error) => { + assert.match(error.message, /HTTP 403/); + assert.equal(error.message.includes('private-token-response'), false); + return true; + }); +}); + +test('workers.dev subdomain reuses existing value and registers only on documented missing code', async () => { + const api = new CloudflareApi('token', async () => json({ subdomain: 'existing' })); + assert.equal(await resolveWorkerHostname(api, settings), 'edgessh.existing.workers.dev'); + let writes = 0; + const fresh = new CloudflareApi('token', async (_url, init) => { + if (init?.method === 'GET') return Response.json({ errors: [{ code: 10007 }] }, { status: 404 }); + writes++; + assert.deepEqual(JSON.parse(String(init?.body)), { subdomain: 'edgessh-aaaaaaaa' }); + return json({}); + }); + assert.equal(await resolveWorkerHostname(fresh, settings), 'edgessh.edgessh-aaaaaaaa.workers.dev'); + assert.equal(writes, 1); + const denied = new CloudflareApi('token', async () => new Response('', { status: 403 })); + await assert.rejects(resolveWorkerHostname(denied, settings), /HTTP 403/); +}); + +test('bootstrap creates OTP and a single application with email policy; rerun is read-only', async () => { + let app: Record | undefined; + const providers: unknown[] = []; + const writes: string[] = []; + const policy = { id: 'policy', name: 'EdgeSSH edgessh administrator', decision: 'allow', include: [{ email: { email: settings.adminEmail } }] }; + const api = new CloudflareApi('token', async (url, init) => { + const path = new URL(String(url)).pathname; + if (init?.method !== 'GET') writes.push(path); + if (path.endsWith('/organizations')) return json({ auth_domain: 'team.cloudflareaccess.com' }); + if (path.endsWith('/identity_providers')) { + if (init?.method === 'POST') { + const body = JSON.parse(String(init.body)); + assert.equal(body.type, 'onetimepin'); + providers.push({ ...body, id: 'otp' }); + return json(providers[0]); + } + return json(providers); + } + if (path.endsWith('/policies')) return json([policy]); + if (path.endsWith('/apps')) { + if (init?.method === 'POST') { + app = { ...JSON.parse(String(init.body)), id: 'app', aud: 'aud' }; + return json(app); + } + return json(app ? [app] : []); + } + throw Error(`Unexpected path ${path}`); + }); + const result = await ensureAccess(api, settings, 'edgessh.example.workers.dev'); + assert.deepEqual(result, { ACCESS_TEAM_DOMAIN: 'team.cloudflareaccess.com', ACCESS_AUD: 'aud' }); + assert.deepEqual(app?.allowed_idps, ['otp']); + assert.deepEqual(app?.policies, [{ + name: policy.name, decision: 'allow', include: policy.include, precedence: 1, + }]); + assert.equal(writes.length, 2); + await ensureAccess(api, settings, 'edgessh.example.workers.dev'); + assert.equal(writes.length, 2); +}); + +test('explicit IdPs support GitHub plus OTP without creating a provider or forcing redirect', async () => { + let payload: Record | undefined; + const api = new CloudflareApi('token', async (url, init) => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/organizations')) return json({ auth_domain: 'team.cloudflareaccess.com' }); + if (path.endsWith('/identity_providers')) { + assert.equal(init?.method, 'GET'); + return json([{ id: 'github', type: 'github' }, { id: 'otp', type: 'onetimepin' }]); + } + if (init?.method === 'POST') { + payload = JSON.parse(String(init.body)); + return json({ id: 'app', aud: 'aud' }); + } + return json([]); + }); + await ensureAccess(api, { ...settings, identityProviderIds: ['github', 'otp'] }, 'ssh.example.com'); + assert.deepEqual(payload?.allowed_idps, ['github', 'otp']); + assert.equal(payload?.auto_redirect_to_identity, false); +}); + +test('existing custom application and GitHub provider configuration are never rewritten', async () => { + const api = new CloudflareApi('token', async (url, init) => { + assert.equal(init?.method, 'GET'); + const path = new URL(String(url)).pathname; + if (path.endsWith('/organizations')) return json({ auth_domain: 'team.cloudflareaccess.com' }); + if (path.endsWith('/policies')) return json([{ name: 'Custom GitHub admins', decision: 'allow', include: [{ github: { name: 'team' } }] }]); + return json([{ id: 'app', aud: 'aud', domain: 'ssh.example.com', type: 'self_hosted', allowed_idps: ['github'], auto_redirect_to_identity: false }]); + }); + assert.equal((await ensureAccess(api, settings, 'ssh.example.com')).ACCESS_AUD, 'aud'); +}); + +test('only managed email changes are applied, preserving policy precedence', async () => { + let writes = 0; + const api = new CloudflareApi('token', async (url, init) => { + const path = new URL(String(url)).pathname; + if (init?.method === 'PUT') { + assert.ok(path.endsWith('/policies/managed')); + assert.deepEqual(JSON.parse(String(init.body)), { + name: 'EdgeSSH edgessh administrator', decision: 'allow', + include: [{ email: { email: settings.adminEmail } }], precedence: 7, + }); + writes++; + return json({}); + } + if (path.endsWith('/organizations')) return json({ auth_domain: 'team.cloudflareaccess.com' }); + if (path.endsWith('/policies')) return json([{ + id: 'managed', name: 'EdgeSSH edgessh administrator', decision: 'allow', + include: [{ email: { email: 'previous@example.com' } }], precedence: 7, + }]); + return json([{ id: 'app', aud: 'aud', domain: 'ssh.example.com', type: 'self_hosted' }]); + }); + await ensureAccess(api, settings, 'ssh.example.com'); + assert.equal(writes, 1); +}); + +test('missing Zero Trust initialization stops before creating anything', async () => { + const api = new CloudflareApi('token', async (url, init) => { + assert.ok(String(url).endsWith('/organizations')); + assert.equal(init?.method, 'GET'); + return json({}); + }); + await assert.rejects(ensureAccess(api, settings, 'ssh.example.com'), /启用 Zero Trust/); +}); + +test('unsafe existing policy is rejected without writes', async () => { + for (const policy of [ + { decision: 'bypass', include: [{ everyone: {} }] }, + { decision: 'allow', include: [{ email_domain: { domain: 'example.com' } }] }, + { decision: 'allow', include: [{ everyone: {} }] }, + ]) { + const api = new CloudflareApi('token', async (url, init) => { + assert.equal(init?.method, 'GET'); + const path = new URL(String(url)).pathname; + if (path.endsWith('/organizations')) return json({ auth_domain: 'team.cloudflareaccess.com' }); + if (path.endsWith('/policies')) return json([policy]); + return json([{ id: 'app', aud: 'aud', domain: 'ssh.example.com', type: 'self_hosted' }]); + }); + await assert.rejects(ensureAccess(api, settings, 'ssh.example.com'), /收紧/); + } +}); + +test('worker lookup does not treat unauthorized or missing secret reads as a fresh deployment', async () => { + const absent = new CloudflareApi('token', async () => json([])); + assert.deepEqual(await readWorkerSecretNames(absent, settings), new Set()); + const existing = new CloudflareApi('token', async (url) => { + if (String(url).includes('/secrets')) return new Response('', { status: 403 }); + return json([{ id: settings.workerName }]); + }); + await assert.rejects(readWorkerSecretNames(existing, settings), /HTTP 403/); +}); + +test('Worker list is unpaginated, including accounts with more than 100 Workers', async () => { + const api = new CloudflareApi('token', async (url) => { + assert.equal(new URL(String(url)).search, ''); + if (String(url).endsWith('/secrets')) return json([{ name: 'ENCRYPTION_KEY' }]); + return json([...Array.from({ length: 100 }, (_, index) => ({ id: `other-${index}` })), { id: settings.workerName }]); + }); + assert.deepEqual(await readWorkerSecretNames(api, settings), new Set(['ENCRYPTION_KEY'])); +}); + +test('existing encryption key is never overwritten, including stale Actions secrets', async () => { + const api = new CloudflareApi('token', async () => { throw Error('must not query'); }); + assert.deepEqual(await prepareEncryptionSecret(api, { + ...settings, secrets: { ENCRYPTION_KEY: 'stale-secret' }, + }, database, new Set(['ENCRYPTION_KEY'])), {}); +}); + +test('new key is 32 random bytes, but missing key with existing ciphertext is a hard failure', async () => { + const empty = new CloudflareApi('token', async () => json([{ success: true, results: [] }])); + const first = await prepareEncryptionSecret(empty, settings, database, new Set()); + const second = await prepareEncryptionSecret(empty, settings, database, new Set()); + assert.equal(Buffer.from(first.ENCRYPTION_KEY!, 'base64').length, 32); + assert.notEqual(first.ENCRYPTION_KEY, second.ENCRYPTION_KEY); + const data = new CloudflareApi('token', async () => json([{ success: true, results: [{ name: 'hosts' }] }])); + await assert.rejects(prepareEncryptionSecret(data, settings, database, new Set()), /恢复原密钥/); + const restored = { ENCRYPTION_KEY: Buffer.alloc(32, 1).toString('base64') }; + assert.deepEqual(await prepareEncryptionSecret(data, { ...settings, secrets: restored }, database, new Set()), restored); +}); + +test('a previously migrated but empty hosts table permits first key generation', async () => { + let queries = 0; + const api = new CloudflareApi('token', async (_url, init) => { + queries++; + const { sql } = JSON.parse(String(init?.body)); + return json([{ success: true, results: sql.includes('sqlite_master') ? [{ name: 'hosts' }] : [] }]); + }); + assert.ok((await prepareEncryptionSecret(api, settings, database, new Set())).ENCRYPTION_KEY); + assert.equal(queries, 2); +}); diff --git a/tests/deployment.test.ts b/tests/deployment.test.ts index f8a87c5..e0dbfa6 100644 --- a/tests/deployment.test.ts +++ b/tests/deployment.test.ts @@ -58,6 +58,8 @@ test('worker, database and custom domain can be configured for another account', const config = createDeploymentConfig(template, custom, { ...database, name: custom.databaseName }); assert.equal(config.name, 'my-ssh'); assert.equal(config.account_id, 'b'.repeat(32)); + assert.equal(config.workers_dev, false); + assert.equal(config.preview_urls, false); assert.deepEqual(config.routes, [{ pattern: 'ssh.example.com', custom_domain: true }]); }); @@ -78,8 +80,11 @@ test('explicit database settings and manually configured template values are res }); test('missing deployment settings fail before provisioning and do not reveal values', () => { - assert.throws(() => readDeploymentSettings(template, {}), /CLOUDFLARE_ACCOUNT_ID.*ENCRYPTION_KEY.*ACCESS_AUD/); - assert.throws(() => readDeploymentSettings(template, { ...env, ACCESS_AUD: ' ' }), /ACCESS_AUD/); + assert.throws(() => readDeploymentSettings(template, {}), /CLOUDFLARE_API_TOKEN/); + const automatic = readDeploymentSettings(template, { CLOUDFLARE_API_TOKEN: 'token', ADMIN_EMAIL: 'Admin@example.com' }); + assert.equal(automatic.accountId, ''); + assert.equal(automatic.adminEmail, 'admin@example.com'); + assert.deepEqual(automatic.secrets, {}); }); test('invalid account, resource, domain and runtime configuration fail validation', () => { @@ -91,6 +96,8 @@ test('invalid account, resource, domain and runtime configuration fail validatio CUSTOM_DOMAIN: 'https://ssh.example.com/path', ENCRYPTION_KEY: Buffer.alloc(16).toString('base64'), ACCESS_TEAM_DOMAIN: 'https://example.cloudflareaccess.com', + ADMIN_EMAIL: 'everyone', + ACCESS_IDP_IDS: 'github', })) { assert.throws(() => readDeploymentSettings(template, { ...env, [name]: value }), new RegExp(name)); } @@ -181,12 +188,15 @@ test('unsuccessful API envelope aborts resource preparation', async () => { await assert.rejects(ensureDatabase(settings, fetcher), /未成功/); }); -test('Actions supplies runtime secrets from Secrets and uses the shared deploy entry point', async () => { +test('Actions asks for email, keeps Token private and uses the shared deploy entry point', async () => { const workflow = await readFile(new URL('../.github/workflows/deploy.yml', import.meta.url), 'utf8'); for (const name of ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']) { - assert.ok(workflow.includes(`${name}: \${{ secrets.${name} }}`)); assert.equal(workflow.includes(`vars.${name}`), false); } + assert.ok(workflow.includes('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}')); + assert.ok(workflow.includes('inputs.admin_email || secrets.ADMIN_EMAIL || vars.ADMIN_EMAIL')); + assert.equal(workflow.includes('secrets.ACCESS_AUD'), false); + assert.equal(workflow.includes('secrets.ACCESS_TEAM_DOMAIN'), false); assert.ok(workflow.includes('CUSTOM_DOMAIN: ${{ secrets.CUSTOM_DOMAIN || vars.CUSTOM_DOMAIN }}')); assert.ok(workflow.includes('run: npm run deploy:validate')); assert.ok(workflow.includes('run: npm run deploy\n')); diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index 5585aaa..ff92d45 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -9,7 +9,8 @@ "noEmit": true, "skipLibCheck": true, "isolatedModules": true, + "erasableSyntaxOnly": true, "forceConsistentCasingInFileNames": true }, - "include": ["scripts/**/*.ts", "tests/deployment.test.ts"] + "include": ["scripts/**/*.ts", "tests/deployment*.test.ts"] }