feat: v2.0.0 邮件接收架构改造 —— 移除 Node,Email Worker 直连 Console(docs/11)
- 两阶段接收:POST /internal/email/authorize(accept/reject/temporary_error)+ POST /internal/email/ingest(raw MIME 直传) - Worker-Console 协议:Authorization: WmailHub-Worker + timestamp/nonce/body-hash/HMAC,nonce 防重放,凭据撤销即时生效 - 数据模型:nodes/node_credentials -> cloudflare_accounts/worker_credentials(迁移 0003),emails/domains 归属 account_id - 接入流程 Connect Cloudflare Account:OAuth 自动同步域名 + 部署账号级 Email Worker + 签发 Worker 凭据;rotate 自动重部署 - 动态邮箱:创建/删除/禁用下一封邮件立即生效;拒绝码 DOMAIN_*/MAILBOX_*;超时/5xx 不伪装成邮箱不存在 - 删除 node/ 目录、旧 /api/internal/nodes 协议、ingress/enrollment 概念;dev.ts 简化为 console + email worker 双进程 - 前端:账号页 Email Worker/凭据管理,设置窗口移除默认 Ingress,仪表盘/文案同步;版本 2.0.0 - 测试 62 项全过(新增 authorize 决策矩阵 + Worker 两阶段临时错误语义);本机虚拟 E2E 12 场景全通 - docs 00-10 同步改写;README 更新
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* - RealCFApi:真实调用 api.cloudflare.com / dash.cloudflare.com(OAuth access token)
|
||||
* - SimCFApi :本地 sim 模式。不访问外网,把"创建"的资源记录到 cf_sim_resources 表,
|
||||
* 让 Add Node 自动安装链路在本地完整可演示、可测试。
|
||||
* 让 Connect Cloudflare Account 自动接入链路在本地完整可演示、可测试。
|
||||
*
|
||||
* Console 通过 env.CF_OAUTH_CLIENT_ID/SECRET 判断模式;两者齐备为 real,否则 sim。
|
||||
*/
|
||||
@@ -51,13 +51,17 @@ export interface CFApi {
|
||||
listAccounts(accessToken: string): Promise<CFAccount[]>;
|
||||
/** 发现账户下的域名 Zone */
|
||||
listZones(accessToken: string, accountId: string): Promise<CFZone[]>;
|
||||
/** 创建或更新 Email Worker(幂等) */
|
||||
/** 创建或更新账号级 Email Worker(幂等) */
|
||||
putEmailWorker(opts: {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
ingressUrl: string;
|
||||
ingressSecret: string;
|
||||
/** Console 对外地址(Worker -> Console) **/
|
||||
consoleUrl: string;
|
||||
/** Worker 身份(与 WorkerCredential.worker_id 一致) **/
|
||||
workerId: string;
|
||||
/** Worker credential secret(HMAC 签名密钥) **/
|
||||
workerSecret: string;
|
||||
}): Promise<{ id: string }>;
|
||||
/** 开启 Zone 的 Email Routing(幂等) */
|
||||
enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean }>;
|
||||
@@ -136,12 +140,13 @@ export class RealCFApi implements CFApi {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
ingressUrl: string;
|
||||
ingressSecret: string;
|
||||
consoleUrl: string;
|
||||
workerId: string;
|
||||
workerSecret: string;
|
||||
}) {
|
||||
// Email Worker 即带 email handler 的 Worker 脚本;ingress 地址与密钥以明文常量打进脚本,
|
||||
// 避免给 Worker 配置 Console 凭据(docs/08:Worker 只持有 Node ingress secret)。
|
||||
const script = renderEmailWorkerScript(opts.ingressUrl, opts.ingressSecret);
|
||||
// Email Worker 即带 email handler 的 Worker 脚本;Console 地址与 Worker 凭据以明文常量打进脚本,
|
||||
// 避免给 Worker 配置额外的 secrets 绑定(docs/11 §8:凭据本身经 HMAC 保护 Console API)。
|
||||
const script = renderEmailWorkerScript(opts.consoleUrl, opts.workerId, opts.workerSecret);
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"metadata",
|
||||
@@ -223,14 +228,16 @@ export class SimCFApi implements CFApi {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
ingressUrl: string;
|
||||
ingressSecret: string;
|
||||
consoleUrl: string;
|
||||
workerId: string;
|
||||
workerSecret: string;
|
||||
}) {
|
||||
// 与真实实现同一模板渲染,但只记录不外发
|
||||
await this.record("email_worker", opts.scriptName, {
|
||||
account_id: opts.accountId,
|
||||
ingress_url: opts.ingressUrl,
|
||||
script: renderEmailWorkerScript(opts.ingressUrl, opts.ingressSecret).length,
|
||||
console_url: opts.consoleUrl,
|
||||
worker_id: opts.workerId,
|
||||
script: renderEmailWorkerScript(opts.consoleUrl, opts.workerId, opts.workerSecret).length,
|
||||
});
|
||||
return { id: opts.scriptName };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 邮件接收管线(docs/06):
|
||||
* Console 收到 Node 转发的 raw MIME 后:
|
||||
* 邮件接收管线(docs/06、docs/11):
|
||||
* Console 收到 Email Worker 提交的 raw MIME 后:
|
||||
* 校验大小限制 -> postal-mime 解析 -> 关联 Mailbox(精确匹配,否则 Domain catch-all)->
|
||||
* raw 写 R2、附件写 R2 -> emails/email_attachments 入库 -> 计算 expires_at ->
|
||||
* 为启用的 Webhook 创建 delivery(投递失败不影响邮件入库)。
|
||||
@@ -45,8 +45,8 @@ export interface IngestInput {
|
||||
mailFrom: string;
|
||||
/** SMTP 信封收件人(Email Worker 传递) **/
|
||||
rcptTo: string;
|
||||
/** 接收该邮件的 Node ID **/
|
||||
nodeId: string;
|
||||
/** 归属 Console 账号 id(Worker credential 所属账号) **/
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
/** # 成员变量: 入库结果 **/
|
||||
@@ -161,13 +161,13 @@ export async function ingestEmail(env: Env, input: IngestInput): Promise<IngestR
|
||||
|
||||
// 入库
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO emails (id, node_id, mailbox_id, message_id, from_address, to_addresses, cc_addresses,
|
||||
`INSERT INTO emails (id, account_id, mailbox_id, message_id, from_address, to_addresses, cc_addresses,
|
||||
subject, text_body, html_body, raw_storage_key, size, received_at, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
emailId,
|
||||
input.nodeId,
|
||||
input.accountId,
|
||||
mailboxId,
|
||||
parsed?.messageId ?? null,
|
||||
fromAddress,
|
||||
@@ -215,7 +215,7 @@ export class IngestError extends Error {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 收件地址 -> mailbox 解析:精确匹配优先,其次域名 catch-all
|
||||
* 收件地址 -> mailbox 解析:精确匹配优先,其次域名 catch-all(仅 active)
|
||||
*/
|
||||
export async function resolveMailbox(env: Env, addresses: string[]): Promise<string | null> {
|
||||
for (const addr of addresses) {
|
||||
|
||||
+175
-151
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* 账号(Node)自动安装管线(v1.1.0):
|
||||
* 添加账号(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 ->
|
||||
* Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱。
|
||||
* 账号接入管线(docs/11,Connect Cloudflare Account):
|
||||
* 添加账号(建 cloudflare_accounts 记录)-> Cloudflare OAuth -> 授权回调 ->
|
||||
* Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱 ->
|
||||
* Email Worker 部署 + WorkerCredential 签发(账号级)。
|
||||
*
|
||||
* Email Workers(Worker 部署 + Email Routing + catch-all 规则)不随 OAuth 自动执行,
|
||||
* 而是在域名列表中按域名单独触发(setupDomainEmail),一个账号只部署一个 Email Worker。
|
||||
*
|
||||
* 所有步骤幂等,重复执行不产生重复资源;账号级步骤状态写入 nodes.provision_steps。
|
||||
* Email Routing/catch-all 规则不随 OAuth 自动执行,而是在域名列表中按域名单独触发
|
||||
* (setupDomainEmail);一个账号只部署一个 Email Worker(幂等,重复执行不产生重复资源)。
|
||||
* 账号级步骤状态写入 cloudflare_accounts.provision_steps。
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
@@ -15,12 +15,12 @@ import { encryptSecret, decryptSecret, decryptConnectionToken } from "../db.ts";
|
||||
import { ids, sha256Hex, DEFAULT_RETENTION_SECONDS } from "../../../shared/src/index.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: 账号安装步骤定义(顺序即展示顺序) **/
|
||||
/** # 成员变量: 账号接入步骤定义(顺序即展示顺序) **/
|
||||
export const PROVISION_STEPS = [
|
||||
{ key: "oauth", label: "Cloudflare OAuth 授权" },
|
||||
{ key: "identity", label: "Node identity 与 credential" },
|
||||
{ key: "discover", label: "Account / Zone 发现" },
|
||||
{ key: "domains", label: "域名与 catch-all 邮箱同步" },
|
||||
{ key: "worker", label: "Email Worker 部署与 Worker 凭据" },
|
||||
] as const;
|
||||
|
||||
/** # 成员变量: 步骤状态 **/
|
||||
@@ -36,164 +36,131 @@ export function initialSteps(): Record<string, { status: StepStatus; error?: str
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 账号的 Email Worker 脚本名(幂等:同一账号固定同名)
|
||||
* 账号的 Email Worker 脚本名(幂等:同一账号固定同名;同时作为 Worker -> Console 的身份 ID)
|
||||
*/
|
||||
export function emailWorkerNameFor(nodeId: string): string {
|
||||
return `wmailhub-email-${nodeId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
|
||||
export function emailWorkerNameFor(accountId: string): string {
|
||||
return `wmailhub-email-${accountId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 添加账号:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。
|
||||
* 返回 enrollment token 供 Node bootstrap(本地由 dev 脚本自动完成,生产在 UI 展示一次)。
|
||||
* 添加账号:创建 cloudflare_accounts 记录(等待 OAuth 授权)。
|
||||
*/
|
||||
export async function createNode(
|
||||
export async function createAccount(
|
||||
env: Env,
|
||||
opts: { name: string; ingressUrl: string },
|
||||
): Promise<{
|
||||
nodeId: string;
|
||||
enrollmentToken: string;
|
||||
ingressSecret: string;
|
||||
}> {
|
||||
const nodeId = ids.node();
|
||||
opts: { name: string },
|
||||
): Promise<{ accountId: string }> {
|
||||
const accountId = ids.account();
|
||||
const now = nowIso();
|
||||
const ingressSecret = "whing_" + crypto.randomUUID().replace(/-/g, "");
|
||||
const enrollmentToken = "wenr_" + crypto.randomUUID().replace(/-/g, "");
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO nodes (id, name, cloudflare_account_id, credential_id, status, version,
|
||||
last_seen_at, ingress_url, ingress_secret_enc, enrollment_token, enrollment_expires_at,
|
||||
`INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status,
|
||||
provision_steps, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, NULL, 'pending_oauth', NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, NULL, NULL, 'pending_oauth', ?, ?, ?)`,
|
||||
)
|
||||
.bind(accountId, opts.name, JSON.stringify(initialSteps()), now, now)
|
||||
.run();
|
||||
return { accountId };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 确保账号存在可用的 Worker credential:
|
||||
* 已有 active credential 直接复用(不泄露 secret);否则签发全新 credential。
|
||||
* 返回 secret 明文仅供部署注入(本地测试/重新部署 Worker 时使用)。
|
||||
*/
|
||||
export async function ensureWorkerCredential(
|
||||
env: Env,
|
||||
accountId: string,
|
||||
workerId: string,
|
||||
): Promise<{ credentialId: string; secret: string | null }> {
|
||||
const now = nowIso();
|
||||
const existing = await env.DB.prepare(
|
||||
"SELECT id FROM worker_credentials WHERE cloudflare_account_id = ? AND worker_id = ? AND status = 'active'",
|
||||
)
|
||||
.bind(accountId, workerId)
|
||||
.first<{ id: string }>();
|
||||
if (existing) return { credentialId: existing.id, secret: null };
|
||||
|
||||
const secret = "whs_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
||||
const credentialId = ids.credential();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES (?, ?, ?, ?, ?, ?, 'active', ?)",
|
||||
)
|
||||
.bind(
|
||||
nodeId,
|
||||
opts.name,
|
||||
opts.ingressUrl,
|
||||
await encryptSecret(env, ingressSecret),
|
||||
enrollmentToken,
|
||||
new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
||||
JSON.stringify(initialSteps()),
|
||||
now,
|
||||
credentialId,
|
||||
accountId,
|
||||
workerId,
|
||||
await sha256Hex(secret),
|
||||
await encryptSecret(env, secret),
|
||||
secret.slice(-4),
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
return { nodeId, enrollmentToken, ingressSecret };
|
||||
return { credentialId, secret };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Node bootstrap enrollment:用一次性 token 换取正式身份。
|
||||
* 每次调用都会轮换出全新 credential(旧的撤销),返回的 secret 仅此一次可见。
|
||||
*/
|
||||
export async function enrollNode(
|
||||
env: Env,
|
||||
enrollmentToken: string,
|
||||
): Promise<{
|
||||
node_id: string;
|
||||
credential_id: string;
|
||||
credential_secret: string;
|
||||
ingress_secret: string;
|
||||
console_url: string | null;
|
||||
} | null> {
|
||||
const node = await env.DB.prepare(
|
||||
"SELECT id, ingress_secret_enc, enrollment_expires_at FROM nodes WHERE enrollment_token = ? AND status != 'revoked'",
|
||||
)
|
||||
.bind(enrollmentToken)
|
||||
.first<{ id: string; ingress_secret_enc: string; enrollment_expires_at: string }>();
|
||||
if (!node) return null;
|
||||
if (node.enrollment_expires_at && node.enrollment_expires_at < nowIso()) return null;
|
||||
|
||||
// 轮换:撤销旧 credential,签发新 credential
|
||||
await env.DB.prepare(
|
||||
"UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'",
|
||||
)
|
||||
.bind(nowIso(), node.id)
|
||||
.run();
|
||||
const credentialId = ids.credential();
|
||||
const secret = "wns_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES (?, ?, ?, ?, 'active', ?)",
|
||||
)
|
||||
.bind(credentialId, node.id, await sha256Hex(secret), await encryptSecret(env, secret), nowIso())
|
||||
.run();
|
||||
await env.DB.prepare(
|
||||
"UPDATE nodes SET credential_id = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(credentialId, nowIso(), node.id)
|
||||
.run();
|
||||
|
||||
return {
|
||||
node_id: node.id,
|
||||
credential_id: credentialId,
|
||||
credential_secret: secret,
|
||||
ingress_secret: await decryptSecret(env, node.ingress_secret_enc),
|
||||
console_url: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** # 成员变量: 授权完成后的完整安装流程参数 **/
|
||||
/** # 成员变量: 授权完成后的完整接入流程参数 **/
|
||||
export interface ProvisionInput {
|
||||
nodeId: string;
|
||||
accountId: string;
|
||||
accessToken: string;
|
||||
accountId?: string;
|
||||
cfAccountId?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* OAuth 授权成功后的自动安装主流程:发现账号 + 同步全部域名(pending)+ catch-all 邮箱。
|
||||
* 幂等,可重复调用;返回是否全部成功;失败时步骤状态里带 error。
|
||||
* OAuth 授权成功后的自动接入主流程:发现账号 + 同步全部域名(pending)+ catch-all 邮箱 +
|
||||
* 部署账号级 Email Worker + 签发 Worker credential。幂等,可重复调用;返回是否全部成功。
|
||||
*/
|
||||
export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
|
||||
export async function provisionAccount(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
|
||||
const cf: CFApi = getCFApi(env, env.DB);
|
||||
const node = await env.DB.prepare("SELECT id, name FROM nodes WHERE id = ?")
|
||||
.bind(input.nodeId)
|
||||
.first<{ id: string; name: string }>();
|
||||
if (!node) return { ok: false, error: "account not found" };
|
||||
const account = await env.DB.prepare("SELECT id, name, status FROM cloudflare_accounts WHERE id = ?")
|
||||
.bind(input.accountId)
|
||||
.first<{ id: string; name: string; status: string }>();
|
||||
if (!account || account.status === "revoked") return { ok: false, error: "account not found" };
|
||||
|
||||
const steps = await loadSteps(env, input.nodeId);
|
||||
const steps = await loadSteps(env, input.accountId);
|
||||
const mark = async (key: string, status: StepStatus, error?: string) => {
|
||||
steps[key] = { status, error };
|
||||
await env.DB.prepare("UPDATE nodes SET provision_steps = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(JSON.stringify(steps), nowIso(), input.nodeId)
|
||||
await env.DB.prepare("UPDATE cloudflare_accounts SET provision_steps = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(JSON.stringify(steps), nowIso(), input.accountId)
|
||||
.run();
|
||||
};
|
||||
|
||||
try {
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'provisioning', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'provisioning', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.accountId)
|
||||
.run();
|
||||
|
||||
// 1. OAuth 授权
|
||||
await mark("oauth", "done");
|
||||
|
||||
// 2. Node identity/credential(在添加账号与 enrollment 时完成)
|
||||
await mark("identity", "done");
|
||||
|
||||
// 3. Account/Zone 发现
|
||||
// 2. Account/Zone 发现
|
||||
await mark("discover", "running");
|
||||
const accounts = await cf.listAccounts(input.accessToken);
|
||||
if (accounts.length === 0) throw new Error("no cloudflare account accessible");
|
||||
const account = input.accountId
|
||||
? accounts.find((a) => a.id === input.accountId) ?? accounts[0]
|
||||
const cfAccount = input.cfAccountId
|
||||
? accounts.find((a) => a.id === input.cfAccountId) ?? accounts[0]
|
||||
: accounts[0];
|
||||
// 自动创建的账号以 Cloudflare 账户名命名
|
||||
const newName = node.name.startsWith("Cloudflare 账号") ? account.name : node.name;
|
||||
await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(account.id, newName, nowIso(), input.nodeId)
|
||||
const newName = account.name.startsWith("Cloudflare 账号") ? cfAccount.name : account.name;
|
||||
await env.DB.prepare(
|
||||
"UPDATE cloudflare_accounts SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(cfAccount.id, newName, nowIso(), input.accountId)
|
||||
.run();
|
||||
const zones = await cf.listZones(input.accessToken, account.id);
|
||||
const zones = await cf.listZones(input.accessToken, cfAccount.id);
|
||||
await mark("discover", "done");
|
||||
|
||||
// 4. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
|
||||
// 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
|
||||
await mark("domains", "running");
|
||||
for (const zone of zones) {
|
||||
await upsertDomain(env, {
|
||||
cloudflareAccountId: account.id,
|
||||
nodeId: input.nodeId,
|
||||
cloudflareAccountId: cfAccount.id,
|
||||
accountId: input.accountId,
|
||||
name: zone.name,
|
||||
zoneId: zone.id,
|
||||
workerId: null,
|
||||
});
|
||||
}
|
||||
const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE node_id = ?")
|
||||
.bind(input.nodeId)
|
||||
const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE account_id = ?")
|
||||
.bind(input.accountId)
|
||||
.all<{ id: string; name: string }>();
|
||||
for (const d of domainRows.results) {
|
||||
await upsertMailbox(env, {
|
||||
@@ -204,8 +171,13 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
|
||||
}
|
||||
await mark("domains", "done");
|
||||
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
// 4. 部署账号级 Email Worker + 签发 Worker credential(docs/11 §14)
|
||||
await mark("worker", "running");
|
||||
await deployAccountWorker(env, input.accountId, input.accessToken);
|
||||
await mark("worker", "done");
|
||||
|
||||
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'active', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.accountId)
|
||||
.run();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
@@ -217,52 +189,104 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
|
||||
break;
|
||||
}
|
||||
}
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'error', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'error', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.accountId)
|
||||
.run();
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 为单个域名配置 Email Workers:部署账号级 Email Worker -> 开启 Email Routing ->
|
||||
* 部署(或更新)账号级 Email Worker:
|
||||
* 确保 Worker credential -> 渲染脚本(Console 地址 + Worker ID + secret)-> 上传 Cloudflare。
|
||||
* credential 已存在时复用旧 secret(重新上传脚本不轮换密钥);secret 仅在首次签发时用于部署。
|
||||
*/
|
||||
export async function deployAccountWorker(env: Env, accountId: string, accessToken: string): Promise<string> {
|
||||
const account = await env.DB.prepare("SELECT id, cloudflare_account_id FROM cloudflare_accounts WHERE id = ?")
|
||||
.bind(accountId)
|
||||
.first<{ id: string; cloudflare_account_id: string | null }>();
|
||||
if (!account?.cloudflare_account_id) throw new Error("account not connected via OAuth");
|
||||
|
||||
const workerId = await getOrCreateWorkerId(env, accountId);
|
||||
const { secret } = await ensureWorkerCredential(env, accountId, workerId);
|
||||
if (!secret) {
|
||||
// 已有 credential:重新上传脚本时需要原 secret,先解密
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT secret_enc FROM worker_credentials WHERE cloudflare_account_id = ? AND worker_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(accountId, workerId)
|
||||
.first<{ secret_enc: string }>();
|
||||
const decrypted = row ? await decryptSecret(env, row.secret_enc) : null;
|
||||
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, decrypted ?? "");
|
||||
return workerId;
|
||||
}
|
||||
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, secret);
|
||||
return workerId;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 账号的 worker_id(幂等:无则生成并写入 cloudflare_accounts.worker_id)
|
||||
*/
|
||||
async function getOrCreateWorkerId(env: Env, accountId: string): Promise<string> {
|
||||
const row = await env.DB.prepare("SELECT worker_id FROM cloudflare_accounts WHERE id = ?")
|
||||
.bind(accountId)
|
||||
.first<{ worker_id: string | null }>();
|
||||
if (row?.worker_id) return row.worker_id;
|
||||
const workerId = emailWorkerNameFor(accountId);
|
||||
await env.DB.prepare("UPDATE cloudflare_accounts SET worker_id = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(workerId, nowIso(), accountId)
|
||||
.run();
|
||||
return workerId;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 上传 Email Worker 脚本(Console 地址取 PUBLIC_BASE_URL,与 OAuth redirect 同源)
|
||||
*/
|
||||
async function putWorkerScript(
|
||||
env: Env,
|
||||
accessToken: string,
|
||||
cfAccountId: string,
|
||||
workerId: string,
|
||||
workerSecret: string,
|
||||
): Promise<void> {
|
||||
const cf: CFApi = getCFApi(env, env.DB);
|
||||
const consoleUrl = env.PUBLIC_BASE_URL ?? "";
|
||||
if (!consoleUrl) throw new Error("console public url unknown (PUBLIC_BASE_URL)");
|
||||
await cf.putEmailWorker({ accessToken, accountId: cfAccountId, scriptName: workerId, consoleUrl, workerId, workerSecret });
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 为单个域名配置 Email Workers:确保账号级 Worker 部署 -> 开启 Email Routing ->
|
||||
* catch-all 规则指向 Worker -> 域名置 active。幂等,失败信息写入 domains.setup_error。
|
||||
*/
|
||||
export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const domain = await env.DB.prepare(
|
||||
"SELECT id, name, zone_id, node_id FROM domains WHERE id = ?",
|
||||
"SELECT id, name, zone_id, account_id FROM domains WHERE id = ?",
|
||||
)
|
||||
.bind(domainId)
|
||||
.first<{ id: string; name: string; zone_id: string | null; node_id: string | null }>();
|
||||
.first<{ id: string; name: string; zone_id: string | null; account_id: string | null }>();
|
||||
if (!domain) return { ok: false, error: "domain not found" };
|
||||
if (!domain.node_id || !domain.zone_id) return { ok: false, error: "domain not bound to a Cloudflare zone" };
|
||||
if (!domain.account_id || !domain.zone_id) return { ok: false, error: "domain not bound to a Cloudflare zone" };
|
||||
|
||||
const node = await env.DB.prepare(
|
||||
"SELECT id, cloudflare_account_id, ingress_url, ingress_secret_enc FROM nodes WHERE id = ? AND status != 'revoked'",
|
||||
const account = await env.DB.prepare(
|
||||
"SELECT id, cloudflare_account_id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'",
|
||||
)
|
||||
.bind(domain.node_id)
|
||||
.first<{ id: string; cloudflare_account_id: string | null; ingress_url: string; ingress_secret_enc: string }>();
|
||||
if (!node?.cloudflare_account_id) return { ok: false, error: "account not connected via OAuth" };
|
||||
.bind(domain.account_id)
|
||||
.first<{ id: string; cloudflare_account_id: string | null }>();
|
||||
if (!account?.cloudflare_account_id) return { ok: false, error: "account not connected via OAuth" };
|
||||
|
||||
const accessToken = await decryptConnectionToken(env);
|
||||
if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" };
|
||||
|
||||
const cf: CFApi = getCFApi(env, env.DB);
|
||||
try {
|
||||
const workerName = emailWorkerNameFor(node.id);
|
||||
await cf.putEmailWorker({
|
||||
accessToken,
|
||||
accountId: node.cloudflare_account_id,
|
||||
scriptName: workerName,
|
||||
ingressUrl: node.ingress_url,
|
||||
ingressSecret: await decryptSecret(env, node.ingress_secret_enc),
|
||||
});
|
||||
const workerId = await deployAccountWorker(env, account.id, accessToken);
|
||||
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });
|
||||
await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName });
|
||||
await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName: workerId });
|
||||
await env.DB.prepare(
|
||||
"UPDATE domains SET worker_id = ?, status = 'active', setup_error = NULL, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(workerName, nowIso(), domainId)
|
||||
.bind(workerId, nowIso(), domainId)
|
||||
.run();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
@@ -275,11 +299,11 @@ export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取并解析 node 的步骤状态
|
||||
* 读取并解析账号的步骤状态
|
||||
*/
|
||||
export async function loadSteps(env: Env, nodeId: string) {
|
||||
const row = await env.DB.prepare("SELECT provision_steps FROM nodes WHERE id = ?")
|
||||
.bind(nodeId)
|
||||
export async function loadSteps(env: Env, accountId: string) {
|
||||
const row = await env.DB.prepare("SELECT provision_steps FROM cloudflare_accounts WHERE id = ?")
|
||||
.bind(accountId)
|
||||
.first<{ provision_steps: string }>();
|
||||
if (!row?.provision_steps) return initialSteps();
|
||||
try {
|
||||
@@ -290,13 +314,13 @@ export async function loadSteps(env: Env, nodeId: string) {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* upsert Domain(按 account+name 幂等)。无 workerId 时保持/置为 pending(待配置 Email Workers)。
|
||||
* upsert Domain(按 cf account+name 幂等)。无 workerId 时保持/置为 pending(待配置 Email Workers)。
|
||||
*/
|
||||
export async function upsertDomain(
|
||||
env: Env,
|
||||
d: {
|
||||
cloudflareAccountId: string;
|
||||
nodeId: string;
|
||||
accountId: string;
|
||||
name: string;
|
||||
zoneId: string;
|
||||
workerId: string | null;
|
||||
@@ -311,23 +335,23 @@ export async function upsertDomain(
|
||||
if (existing) {
|
||||
// 已配置过 Worker 的域名不回退状态;仅当新配置带 worker 时更新
|
||||
if (existing.worker_id || !d.workerId) {
|
||||
await env.DB.prepare("UPDATE domains SET node_id = ?, zone_id = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(d.nodeId, d.zoneId, now, existing.id)
|
||||
await env.DB.prepare("UPDATE domains SET account_id = ?, zone_id = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(d.accountId, d.zoneId, now, existing.id)
|
||||
.run();
|
||||
} else {
|
||||
await env.DB.prepare(
|
||||
"UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
|
||||
"UPDATE domains SET account_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(d.nodeId, d.zoneId, d.workerId, now, existing.id)
|
||||
.bind(d.accountId, d.zoneId, d.workerId, now, existing.id)
|
||||
.run();
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
const id = ids.domain();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId ? "active" : "pending", d.workerId, d.zoneId, now, now)
|
||||
.bind(id, d.cloudflareAccountId, d.accountId, d.name, d.workerId ? "active" : "pending", d.workerId, d.zoneId, now, now)
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user