diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index cc7ff11..3cb3baa 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -67,7 +67,7 @@ API Token 只存 GitHub Secret,不放普通变量、代码或命令行输入 1. 校验本地配置,运行类型检查、测试、前端构建和 Wrangler dry-run。 2. 自动发现唯一账户(显式账户 ID 优先),读取现有 Worker Secret **名称**,不尝试读取密钥明文。 3. 有 `CUSTOM_DOMAIN` 时使用该入口;否则读取账户 `workers.dev` 子域,未注册时自动注册确定性名称。已有子域不改名,避免影响其他 Worker。 -4. 根据 `AUTH_PROVIDER` 仅准备所选认证:Cloudflare 核对实际 Access 应用、策略、Team Domain 与 AUD,GitHub 首次解析并固定管理员数字 ID,后续直接复用。 +4. 根据 `AUTH_PROVIDER` 仅准备所选认证:Cloudflare 普通重部署核对实际入口仍有 Access 网关,首次启用或从 GitHub 切回时核对应用、策略、Team Domain 与 AUD;GitHub 首次解析并固定管理员数字 ID,后续直接复用。 5. GitHub 模式不调用 Zero Trust API;Cloudflare 模式不需要 GitHub OAuth 参数。既有 Access 应用不重写人工 IdP 配置。 6. 按 ID 或名称复用 D1,不存在才创建;指定 ID 不存在时直接失败,不用新空库替代。 7. 已有 `ENCRYPTION_KEY` 则保留;没有密钥且 D1 没有主机资料时,用安全随机数生成 32 字节密钥。 @@ -113,7 +113,7 @@ EdgeSSH-Auto-Update: true - 新部署不需要 GitHub 写 Secrets 权限或额外 GitHub Token。生成的值不写入文件、命令行参数或 Actions artifact。 - 重跑、推送新代码时保留原加密密钥。GitHub 中遗留的同名密钥不会替换 Worker 中的密钥。 - **已有 D1 主机资料但缺少密钥时停止部署。** 必须恢复原密钥,不能生成新密钥假装修复。Cloudflare API 不提供 Secret 明文读回,自动生成的密钥也不会显示给用户;不要删除 Worker/Secret。需要独立灾备时,可在首次部署前自行生成并安全备份 32 字节 Base64 密钥,再保存为 `ENCRYPTION_KEY` GitHub Secret。 -- 旧部署无需重新输入邮箱,也不要求复制 Secret 回 GitHub;Cloudflare 模式每次从实际 Access 应用重新核对 Team Domain、AUD 与策略,不以 Secret 名称代替有效性检查,也不会改写人工维护的 IdP 或身份策略。 +- 旧部署无需重新输入邮箱,也不要求复制 Secret 回 GitHub;同为 Cloudflare 模式的普通重部署会实际探测入口仍受 Access 保护并保留现有 Secret,不要求新增 Zero Trust API 权限。首次启用或从 GitHub 切回时才从 Access 应用核对 Team Domain、AUD 与策略;任何路径都不会仅凭 Secret 名称判定有效,也不会改写人工维护的 IdP 或身份策略。 - 若需要自动创建/管理 Access,或更换 hostname,请提供 `ADMIN_EMAIL`。自动管理使用账户级 Access API;原有 Zone 级应用请先核对,不要在同一 hostname 叠加应用。 - 切换认证方式不改变 `ENCRYPTION_KEY`、D1 和管理员资料所有者。不要通过更换密钥来切换登录方式。 diff --git a/docs/ZERO_TRUST.md b/docs/ZERO_TRUST.md index 87cd999..78f1b7a 100644 --- a/docs/ZERO_TRUST.md +++ b/docs/ZERO_TRUST.md @@ -131,7 +131,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` 通过标准输入写入机密,后续保留 Worker 中的加密密钥。它们不会写入 `wrangler.toml`、临时文件、普通变量或提交记录。普通重部署会从实际 Access 应用重新取得 Team Domain 与 AUD,并核对现有策略;不会仅凭旧 Secret 名称假定入口有效,也不会覆盖人工维护的 IdP 或明确身份策略。 +`Deploy` 通过标准输入写入机密,后续保留 Worker 中的加密密钥。它们不会写入 `wrangler.toml`、临时文件、普通变量或提交记录。同为 Cloudflare 模式的普通重部署会实际确认入口仍受 Access 保护并保留已有 Team Domain/AUD;首次启用或从 GitHub 切回时才通过 Access API 核对应用与策略。部署不会仅凭旧 Secret 名称假定入口有效,也不会覆盖人工维护的 IdP 或明确身份策略。 `ENCRYPTION_KEY` 的生成要求和保管注意事项见[部署指南](../DEPLOYMENT.md#worker-runtime-secrets)。生产部署不需要在本地执行 Wrangler 机密(Secret)或 Worker 部署命令。 diff --git a/scripts/deploy.ts b/scripts/deploy.ts index b9be47d..37b3e50 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -45,6 +45,11 @@ async function main(): Promise { const hostname = settings.customDomain || await resolveWorkerHostname(api, settings); const database = await ensureDatabase(settings); const workspace = await readWorkspaceState(api, settings, database); + const deployedProvider = workspace.authProvider + ?? await readWorkerVariable(api, settings, 'AUTH_PROVIDER'); + if (deployedProvider !== undefined && deployedProvider !== 'cloudflare' && deployedProvider !== 'github') { + throw new Error('已部署的认证方式无效,停止自动覆盖。'); + } const deployedGitHubId = workspace.githubAdminId ?? (settings.authProvider === 'github' ? await readWorkerVariable(api, settings, 'GITHUB_ADMIN_ID') : undefined); if (deployedGitHubId && !/^[1-9]\d*$/.test(deployedGitHubId)) { @@ -53,7 +58,7 @@ async function main(): Promise { const authentication = await prepareAuthentication(api, settings, hostname, { existingSecrets, fixedGithubAdminId: deployedGitHubId, - previousProvider: workspace.authProvider, + previousProvider: deployedProvider, }); const secrets = { ...authentication.secrets, ...await prepareEncryptionSecret(api, settings, database, existingSecrets) }; maskSecrets(secrets); diff --git a/scripts/deployment-auth.ts b/scripts/deployment-auth.ts index 9827226..ff6b4c9 100644 --- a/scripts/deployment-auth.ts +++ b/scripts/deployment-auth.ts @@ -21,7 +21,25 @@ export async function prepareAuthentication( ): Promise<{ secrets: Partial; githubAdminId?: string }> { const fetcher = options.fetcher ?? fetch; if (settings.authProvider === 'cloudflare') { - // 每次从对应 Access 应用刷新 Team Domain/AUD;Secret 名称本身不能证明入口仍然有效。 + const canRetain = options.previousProvider === 'cloudflare' + && requiredAuthSecrets('cloudflare').every((name) => options.existingSecrets?.has(name)); + if (canRetain) { + try { + const response = await fetcher(`https://${hostname}/api/auth/me`, { + redirect: 'manual', signal: AbortSignal.timeout(30_000), + }); + const location = response.headers.get('Location'); + const target = location ? new URL(location, `https://${hostname}`) : null; + if (target && (target.hostname.endsWith('.cloudflareaccess.com') + || target.pathname.startsWith('/cdn-cgi/access/'))) { + console.log('已确认当前入口仍由 Cloudflare Access 保护,保留现有认证 Secret。'); + return { secrets: {} }; + } + } catch { + throw new Error('无法确认当前入口的 Cloudflare Access 登录是否可用,请检查域名后重试。'); + } + } + // 首次启用、切回 Access 或现有配置不完整时,必须从对应应用刷新配置。 return { secrets: await ensureAccess(api, settings, hostname) }; } // GitHub 新部署完全不调用 Zero Trust API。旧域名若仍在 Access 后面,明确停止, diff --git a/tests/deployment-auth.test.ts b/tests/deployment-auth.test.ts index 7254456..0aa1853 100644 --- a/tests/deployment-auth.test.ts +++ b/tests/deployment-auth.test.ts @@ -100,7 +100,19 @@ test('a mode switch stops rather than leaving GitHub behind an existing Access l }), /无法确认入口/); }); -test('Cloudflare redeployment validates the real Access app instead of trusting secret names', async () => { +test('ordinary Cloudflare redeployment confirms the live Access gateway without requiring Zero Trust API', async () => { + const cf = readDeploymentSettings(template, { CLOUDFLARE_API_TOKEN: 'token' }); + const api = new CloudflareApi('token', async () => { throw Error('must not call Zero Trust'); }); + assert.deepEqual(await prepareAuthentication(api, cf, 'ssh.example.com', { + previousProvider: 'cloudflare', + existingSecrets: new Set(['ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']), + fetcher: async () => new Response(null, { + status: 302, headers: { Location: 'https://team.cloudflareaccess.com/cdn-cgi/access/login' }, + }), + }), { secrets: {} }); +}); + +test('switching to Cloudflare validates the real Access app instead of trusting old secret names', async () => { const cf = readDeploymentSettings(template, { CLOUDFLARE_API_TOKEN: 'token' }); const paths: string[] = []; const api = new CloudflareApi('token', async (url) => { @@ -113,7 +125,9 @@ test('Cloudflare redeployment validates the real Access app instead of trusting }]); throw Error(`unexpected ${path}`); }); - assert.deepEqual(await prepareAuthentication(api, cf, 'ssh.example.com'), { + assert.deepEqual(await prepareAuthentication(api, cf, 'ssh.example.com', { + previousProvider: 'github', existingSecrets: new Set(['ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']), + }), { secrets: { ACCESS_TEAM_DOMAIN: 'team.cloudflareaccess.com', ACCESS_AUD: 'current-aud' }, }); assert.ok(paths.some((path) => path.endsWith('/organizations')));