feat(deploy): automate Access bootstrap and preserve Worker secrets

自动配置 Access、OTP 与邮箱授权,保留 Worker 密钥并预留 GitHub IdP 扩展。
This commit is contained in:
gdz66601 committed 2026-09-21 23:08:50 +08:00
1 parent 1a2cd1cd87
commit 3fa5257d93
12 files changed
+661 -204

No files matched your search

+117
View File
@@ -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<string> {
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<AccessApplication>(`${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<IdentityProvider>(`${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<IdentityProvider>(`${base}/identity_providers`, 'POST', {
name: 'Email PIN', type: 'onetimepin', config: {},
});
}
providerIds = [otp.id];
}
// 登录方式仅是应用创建时的默认值;以后重跑不覆盖控制台添加的 GitHub 等 IdP。
// 应用和内嵌 Allow 策略一起创建,避免产生可访问但没有授权规则的中间状态。
app = await api.request<AccessApplication>(`${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<AccessPolicy>(`${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<string, unknown>;
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 };
}
+71
View File
@@ -0,0 +1,71 @@
const apiUrl = 'https://api.cloudflare.com/client/v4';
interface ApiResult<T> {
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<T>(path: string, method: string, body?: unknown): Promise<ApiResult<T>> {
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<T>;
if (!payload.success) throw new Error(`Cloudflare ${method} 请求未成功,请检查权限及资源配置。`);
return payload;
}
async request<T>(path: string, method = 'GET', body?: unknown): Promise<T> {
return (await this.send<T>(path, method, body)).result;
}
async list<T>(path: string): Promise<T[]> {
const items: T[] = [];
for (let page = 1; ; page++) {
const payload = await this.send<T[]>(`${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<string> {
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;
}
+31 -2
View File
@@ -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<void> {
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) => {
+30 -13
View File
@@ -10,7 +10,9 @@ export interface DeploymentSettings {
databaseName: string;
databaseId?: string;
customDomain?: string;
secrets: RuntimeSecrets;
adminEmail?: string;
identityProviderIds: string[];
secrets: Partial<RuntimeSecrets>;
}
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<RuntimeSecrets>;
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 }
+44
View File
@@ -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<Set<string>> {
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<string>,
): Promise<Partial<RuntimeSecrets>> {
// 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<RuntimeSecrets>): void {
if (process.env.GITHUB_ACTIONS === 'true') {
for (const value of Object.values(secrets)) console.log(`::add-mask::${value}`);
}
}