feat: WmailHub v1.0.0 —— Cloudflare Email Workers 邮件接收与转发平台(本地可用版本)
- Console:React 19 + antd 6.6.5 前端(登录 admin/admin)+ Hono 管理 API (Nodes/Domains/Mailboxes/Emails/Webhooks/Keys/OAuth/Admin) - Node:Bun 转发节点(HMAC 签名、有限重试、心跳、结构化日志、优雅退出) - Email Worker:Cloudflare email handler + 本地联调入口 - shared:HMAC 协议(docs/04)、wmh_ Key(32 位)、AES-GCM 加密、SSRF 防护 - 邮件管线:postal-mime 解析、附件、catch-all、限额、malformed 降级、TTL 1 天 - Webhook:签名投递、指数退避重试、SSRF 校验、投递日志 - API v1 + MCP(JSON-RPC,六工具,凭据 kind 隔离) - Cloudflare OAuth:真实 + 本地 sim 模式,自动安装管线(幂等,无需手工命令) - 测试:bun test 41 例(协议安全/管线/TTL/Webhook/SSRF/MCP/OAuth/鉴权)+ Playwright UI 冒烟 - LOGO/favicon 由 codex CLI 生成
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Node 自动安装管线(docs/02 流程):
|
||||
* Add Node(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 ->
|
||||
* Account/Zone 发现 -> 创建/更新 Email Worker -> 配置 Email Routing ->
|
||||
* 创建 Domain/Mailbox -> Node Ready。
|
||||
*
|
||||
* 所有步骤幂等,重复执行不产生重复资源;每步状态写入 nodes.provision_steps,
|
||||
* 供前端安装进度展示。全程不要求用户复制安装命令(docs/10 Phase 4)。
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
import { getCFApi, type CFApi } from "./cfapi.ts";
|
||||
import { encryptSecret, decryptSecret } 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: "email_worker", label: "Email Worker 创建/更新" },
|
||||
{ key: "email_routing", label: "Email Routing 配置" },
|
||||
{ key: "domains", label: "Domain 创建" },
|
||||
{ key: "mailboxes", label: "Mailbox 创建" },
|
||||
] as const;
|
||||
|
||||
/** # 成员变量: 步骤状态 **/
|
||||
export type StepStatus = "pending" | "running" | "done" | "error";
|
||||
|
||||
/** # 函数
|
||||
* 初始化步骤状态 JSON
|
||||
*/
|
||||
export function initialSteps(): Record<string, { status: StepStatus; error?: string }> {
|
||||
const out: Record<string, { status: StepStatus; error?: string }> = {};
|
||||
for (const s of PROVISION_STEPS) out[s.key] = { status: "pending" };
|
||||
return out;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Node 的 Email Worker 脚本名(幂等:同一 Node 固定同名)
|
||||
*/
|
||||
export function emailWorkerNameFor(nodeId: string): string {
|
||||
return `wmailhub-email-${nodeId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Add Node:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。
|
||||
* 返回 enrollment token 供 Node bootstrap(本地由 dev 脚本自动完成,生产在 UI 展示一次)。
|
||||
*/
|
||||
export async function createNode(
|
||||
env: Env,
|
||||
opts: { name: string; ingressUrl: string },
|
||||
): Promise<{
|
||||
nodeId: string;
|
||||
enrollmentToken: string;
|
||||
ingressSecret: string;
|
||||
}> {
|
||||
const nodeId = ids.node();
|
||||
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,
|
||||
provision_steps, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, NULL, 'pending_oauth', NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
nodeId,
|
||||
opts.name,
|
||||
opts.ingressUrl,
|
||||
await encryptText(env, ingressSecret),
|
||||
enrollmentToken,
|
||||
new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
||||
JSON.stringify(initialSteps()),
|
||||
now,
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
return { nodeId, enrollmentToken, ingressSecret };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 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 = ?, status = CASE WHEN status = 'pending_oauth' THEN status ELSE status END, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(credentialId, nowIso(), node.id)
|
||||
.run();
|
||||
|
||||
return {
|
||||
node_id: node.id,
|
||||
credential_id: credentialId,
|
||||
credential_secret: secret,
|
||||
ingress_secret: await decryptText(env, node.ingress_secret_enc),
|
||||
console_url: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function encryptText(env: Env, text: string): Promise<string> {
|
||||
return encryptSecret(env, text);
|
||||
}
|
||||
|
||||
async function decryptText(env: Env, blob: string): Promise<string> {
|
||||
return decryptSecret(env, blob);
|
||||
}
|
||||
|
||||
/** # 成员变量: 授权完成后的完整安装流程参数 **/
|
||||
export interface ProvisionInput {
|
||||
nodeId: string;
|
||||
accessToken: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* OAuth 授权成功后的自动安装主流程(幂等,可重复调用)。
|
||||
* 返回是否全部成功;失败时步骤状态里带 error。
|
||||
*/
|
||||
export async function provisionNode(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, ingress_url, ingress_secret_enc FROM nodes WHERE id = ?",
|
||||
)
|
||||
.bind(input.nodeId)
|
||||
.first<{ id: string; name: string; ingress_url: string; ingress_secret_enc: string }>();
|
||||
if (!node) return { ok: false, error: "node not found" };
|
||||
|
||||
const steps = await loadSteps(env, input.nodeId);
|
||||
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)
|
||||
.run();
|
||||
};
|
||||
|
||||
try {
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'provisioning', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
.run();
|
||||
|
||||
// 1. OAuth 授权
|
||||
await mark("oauth", "done");
|
||||
|
||||
// 2. Node identity/credential(在 Add Node 与 enrollment 时完成)
|
||||
await mark("identity", "done");
|
||||
|
||||
// 3. 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]
|
||||
: accounts[0];
|
||||
await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(account.id, nowIso(), input.nodeId)
|
||||
.run();
|
||||
const zones = await cf.listZones(input.accessToken, account.id);
|
||||
await mark("discover", "done");
|
||||
|
||||
// 4. Email Worker(幂等上传)
|
||||
await mark("email_worker", "running");
|
||||
const workerName = emailWorkerNameFor(input.nodeId);
|
||||
const ingressSecret = await decryptText(env, node.ingress_secret_enc);
|
||||
await cf.putEmailWorker({
|
||||
accessToken: input.accessToken,
|
||||
accountId: account.id,
|
||||
scriptName: workerName,
|
||||
ingressUrl: node.ingress_url,
|
||||
ingressSecret,
|
||||
});
|
||||
await mark("email_worker", "done");
|
||||
|
||||
// 5+6. 每个 Zone:启用 Email Routing + catch-all -> Worker,并 upsert Domain
|
||||
await mark("email_routing", "running");
|
||||
for (const zone of zones) {
|
||||
await cf.enableEmailRouting({ accessToken: input.accessToken, zoneId: zone.id });
|
||||
await cf.setCatchAllRule({
|
||||
accessToken: input.accessToken,
|
||||
zoneId: zone.id,
|
||||
workerName,
|
||||
});
|
||||
await upsertDomain(env, {
|
||||
cloudflareAccountId: account.id,
|
||||
nodeId: input.nodeId,
|
||||
name: zone.name,
|
||||
zoneId: zone.id,
|
||||
workerId: workerName,
|
||||
});
|
||||
}
|
||||
await mark("email_routing", "done");
|
||||
await mark("domains", "done");
|
||||
|
||||
// 7. 每个 Domain 创建 catch-all Mailbox(*@domain)
|
||||
await mark("mailboxes", "running");
|
||||
const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE node_id = ?")
|
||||
.bind(input.nodeId)
|
||||
.all<{ id: string; name: string }>();
|
||||
for (const d of domainRows.results) {
|
||||
await upsertMailbox(env, {
|
||||
domainId: d.id,
|
||||
address: `*@${d.name}`,
|
||||
isCatchAll: true,
|
||||
});
|
||||
}
|
||||
await mark("mailboxes", "done");
|
||||
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
.run();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// 标记第一个未完成步骤为 error
|
||||
for (const s of PROVISION_STEPS) {
|
||||
if (steps[s.key]?.status === "running") {
|
||||
await mark(s.key, "error", message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'error', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
.run();
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取并解析 node 的步骤状态
|
||||
*/
|
||||
export async function loadSteps(env: Env, nodeId: string) {
|
||||
const row = await env.DB.prepare("SELECT provision_steps FROM nodes WHERE id = ?")
|
||||
.bind(nodeId)
|
||||
.first<{ provision_steps: string }>();
|
||||
if (!row?.provision_steps) return initialSteps();
|
||||
try {
|
||||
return JSON.parse(row.provision_steps) as Record<string, { status: StepStatus; error?: string }>;
|
||||
} catch {
|
||||
return initialSteps();
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* upsert Domain(按 account+name 幂等)
|
||||
*/
|
||||
export async function upsertDomain(
|
||||
env: Env,
|
||||
d: {
|
||||
cloudflareAccountId: string;
|
||||
nodeId: string;
|
||||
name: string;
|
||||
zoneId: string;
|
||||
workerId: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const existing = await env.DB.prepare(
|
||||
"SELECT id FROM domains WHERE cloudflare_account_id = ? AND name = ?",
|
||||
)
|
||||
.bind(d.cloudflareAccountId, d.name)
|
||||
.first<{ id: string }>();
|
||||
const now = nowIso();
|
||||
if (existing) {
|
||||
await env.DB.prepare(
|
||||
"UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(d.nodeId, 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 (?, ?, ?, ?, 'active', ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId, d.zoneId, now, now)
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* upsert Mailbox(按 address 幂等)
|
||||
*/
|
||||
export async function upsertMailbox(
|
||||
env: Env,
|
||||
m: { domainId: string; address: string; isCatchAll?: boolean; retentionSeconds?: number },
|
||||
): Promise<string> {
|
||||
const existing = await env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?")
|
||||
.bind(m.address.toLowerCase())
|
||||
.first<{ id: string }>();
|
||||
const now = nowIso();
|
||||
if (existing) return existing.id;
|
||||
const id = ids.mailbox();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO mailboxes (id, domain_id, address, status, retention_seconds, is_catch_all, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
m.domainId,
|
||||
m.address.toLowerCase(),
|
||||
m.retentionSeconds ?? DEFAULT_RETENTION_SECONDS,
|
||||
m.isCatchAll ? 1 : 0,
|
||||
now,
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
Reference in New Issue
Block a user