fix(deploy): 区分 Access 重部署与认证切换 / distinguish Access redeploys from auth switches

This commit is contained in:
gdz66601 committed 2026-09-23 07:13:21 +08:00
1 parent c6e5786bba
commit ed523def41
5 files changed
+44 -7

No files matched your search

+2 -2
View File
@@ -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 和管理员资料所有者。不要通过更换密钥来切换登录方式。
+1 -1
View File
@@ -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 部署命令。
+6 -1
View File
@@ -45,6 +45,11 @@ async function main(): Promise<void> {
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<void> {
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);
+19 -1
View File
@@ -21,7 +21,25 @@ export async function prepareAuthentication(
): Promise<{ secrets: Partial<RuntimeSecrets>; 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 后面,明确停止,
+16 -2
View File
@@ -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')));