v2.10.0: 邮件链路 401 修复 + 一账号一 token + 重装 Worker 按钮——workerAuth 同名脚本撞车时按候选逐一验签确定归属(修 itekmedia 收不到件);凭据 token(credential id)烤进 Worker 脚本、Console 按 token 精确鉴权(脚本名仅作旧版兼容);账号详情新增「重新安装 Worker 脚本」

This commit is contained in:
bigcode
2026-10-05 02:04:13 +08:00
parent 1c7dba025b
commit 2dede46509
19 changed files with 242 additions and 68 deletions
+4
View File
@@ -157,6 +157,10 @@ export const rotateAccount = (id: string) =>
export const getAccountWorkerSecret = (id: string) =>
api<{ worker_id: string; secret: string }>(`/api/accounts/${id}/worker-secret`);
/** 重新安装 Worker 脚本:不轮换凭据,用当前模板(Console 地址 + 凭据 token + secret)重渲染上传 */
export const reinstallAccountWorker = (id: string) =>
api<{ ok: boolean; worker_id: string }>(`/api/accounts/${id}/reinstall-worker`, "POST");
/* ---- OAuth ---- */
/** OAuth 是否已配置(cloudflare = 可用;unconfigured = 缺少 client id/secret) */
+18
View File
@@ -24,6 +24,7 @@ import {
getAccount,
listAccounts,
oauthStart,
reinstallAccountWorker,
rotateAccount,
updateAccount,
type AccountDetail,
@@ -428,6 +429,23 @@ function AccountDetailDrawer({
Email Worker 以该凭据直连 Console(/internal/email/authorize 与 /ingest);轮换后已配置的域名会自动重新部署。
</Typography.Text>
<Space wrap>
<Button
icon={<ReloadOutlined />}
onClick={() => {
modal.confirm({
title: `重新安装 ${detail.account.name} 的 Worker 脚本?`,
content: "用当前版本模板重新渲染并上传(凭据不变,已配置域名立即生效);Console 升级后建议执行一次。",
okText: "重新安装",
onOk: async () => {
const res = await reinstallAccountWorker(detail.account.id);
message.success(`Worker 脚本已重新安装(${res.worker_id})`);
onReload();
},
});
}}
>
重新安装 Worker 脚本
</Button>
<Button
onClick={async () => {
const res = await rotateAccount(detail.account.id);
+1
View File
@@ -28,6 +28,7 @@ const ACTION_LABEL: Record<string, string> = {
"account.delete": "删除账号",
"account.batch_delete": "批量删除账号",
"account.rotate": "轮换 Worker 凭据",
"account.reinstall_worker": "重新安装 Worker 脚本",
"domain.setup": "配置 Email Workers",
"domain.delete": "删除域名",
"domain.batch_delete": "批量删除域名",
+52 -24
View File
@@ -50,32 +50,60 @@ export type EmailDecision =
*/
async function workerAuth(c: Ctx, next: Next) {
const raw = new Uint8Array(await c.req.arrayBuffer());
const cred = await c.env.DB.prepare(
`SELECT wc.id, wc.secret_enc, wc.cloudflare_account_id, ca.status AS account_status
FROM worker_credentials wc JOIN cloudflare_accounts ca ON ca.id = wc.cloudflare_account_id
WHERE wc.worker_id = ? AND wc.status = 'active' ORDER BY wc.created_at DESC LIMIT 1`,
)
.bind((c.req.header("Authorization") ?? "").replace(/^WmailHub-Worker\s+/, "").trim() || "\u0000")
.first<{ id: string; secret_enc: string; cloudflare_account_id: string; account_status: string }>();
if (!cred) return c.json({ error: "unknown worker or credential revoked" }, 401);
const secret = await decryptSecret(c.env, cred.secret_enc);
const verdict = await verifyWorkerRequest({
secret,
method: c.req.method,
url: c.req.url,
body: raw,
headers: c.req.raw.headers,
});
if (!verdict.ok) return c.json({ error: verdict.error }, verdict.status);
// 一个账号一个 token(v2.10.0):凭据 id 即唯一 token,Worker 以它表明身份,先精确匹配。
// 兼容旧版 Worker:身份是 worker 脚本名——脚本名只在单个 Cloudflare 账号内唯一,多账号可能
// 同名(如都叫 wmailhub-email),此时按名取全部候选逐一验签确定归属;
// 只取最新一条会让先接入账号的 Worker 全部 401(v2.9.3~v2.10.0 修复)。
const token = (c.req.header("Authorization") ?? "").replace(/^WmailHub-Worker\s+/, "").trim();
type CredRow = { id: string; secret_enc: string; cloudflare_account_id: string };
let candidates: { results: CredRow[] } = { results: [] };
if (token) {
candidates = await c.env.DB.prepare(
`SELECT wc.id, wc.secret_enc, wc.cloudflare_account_id
FROM worker_credentials wc JOIN cloudflare_accounts ca ON ca.id = wc.cloudflare_account_id
WHERE wc.id = ? AND wc.status = 'active' AND ca.status != 'revoked'`,
)
.bind(token)
.all<CredRow>();
if (candidates.results.length === 0) {
candidates = await c.env.DB.prepare(
`SELECT wc.id, wc.secret_enc, wc.cloudflare_account_id
FROM worker_credentials wc JOIN cloudflare_accounts ca ON ca.id = wc.cloudflare_account_id
WHERE wc.worker_id = ? AND wc.status = 'active' AND ca.status != 'revoked'
ORDER BY wc.created_at DESC`,
)
.bind(token)
.all<CredRow>();
}
}
let matched: { id: string; cloudflare_account_id: string; nonce: string; timestamp: number } | null = null;
let lastError = "unknown worker or credential revoked";
let lastStatus: 400 | 401 = 401;
for (const cand of candidates.results) {
const secret = await decryptSecret(c.env, cand.secret_enc);
const verdict = await verifyWorkerRequest({
secret,
method: c.req.method,
url: c.req.url,
body: raw,
headers: c.req.raw.headers,
});
if (verdict.ok) {
matched = { id: cand.id, cloudflare_account_id: cand.cloudflare_account_id, nonce: verdict.nonce, timestamp: verdict.timestamp };
break;
}
lastError = verdict.error;
lastStatus = verdict.status;
}
if (!matched) return c.json({ error: lastError }, lastStatus);
// nonce 防重放:主键冲突即拒绝
try {
await c.env.DB.prepare("INSERT INTO nonces (nonce, account_id, expires_at) VALUES (?, ?, ?)")
.bind(
verdict.nonce,
cred.cloudflare_account_id,
new Date((verdict.timestamp + NONCE_TTL_SECONDS) * 1000).toISOString(),
matched.nonce,
matched.cloudflare_account_id,
new Date((matched.timestamp + NONCE_TTL_SECONDS) * 1000).toISOString(),
)
.run();
} catch {
@@ -88,15 +116,15 @@ async function workerAuth(c: Ctx, next: Next) {
FROM worker_credentials wc JOIN cloudflare_accounts ca ON ca.id = wc.cloudflare_account_id
WHERE wc.id = ?`,
)
.bind(cred.id)
.bind(matched.id)
.first<{ cred_status: string; account_status: string }>();
if (!fresh || fresh.cred_status !== "active" || fresh.account_status === "revoked") {
return c.json({ error: "credential revoked" }, 401);
}
c.set("rawBody", raw);
c.set("workerCredentialId", cred.id);
c.set("workerAccountId", cred.cloudflare_account_id);
c.set("workerCredentialId", matched.id);
c.set("workerAccountId", matched.cloudflare_account_id);
await next();
}
+28 -1
View File
@@ -16,7 +16,7 @@
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { loadSteps, setupDomainEmail, upsertMailbox, createAccount, provisionAccount, teardownAccountResources, teardownDomainRouting, type TeardownResult } from "../services/provision.ts";
import { loadSteps, setupDomainEmail, upsertMailbox, createAccount, provisionAccount, deployAccountWorker, teardownAccountResources, teardownDomainRouting, type TeardownResult } from "../services/provision.ts";
import { getCFApi } from "../services/cfapi.ts";
import { logOperation, operatorName } from "../services/logs.ts";
import { encryptSecret, keyHash, decryptAccountToken, decryptSecret, saveConnection } from "../db.ts";
@@ -279,6 +279,33 @@ function installAccountRoutes(app: App) {
/** 轮换 Worker credential:撤销旧 credential 并签发新 secret(仅本次响应可见);
* 已配置 Email Workers 的域名自动重新部署脚本以携带新 secret。 */
/** 重新安装 Worker 脚本:不轮换凭据,按当前模板(Console 地址 + 凭据 token + secret)重渲染并上传。
* 用于 Console 升级后把已有账号的 Worker 推到最新脚本(如 v2.10.0 一账号一 token 版);幂等。 */
app.post("/accounts/:id/reinstall-worker", async (c) => {
const id = c.req.param("id");
const account = await c.env.DB.prepare(
"SELECT id, name, worker_id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'",
)
.bind(id)
.first<{ id: string; name: string; worker_id: string | null }>();
if (!account) return c.json({ error: "not found" }, 404);
if (!account.worker_id) return c.json({ error: "worker not provisioned yet" }, 400);
const accessToken = await decryptAccountToken(c.env, id);
if (!accessToken) return c.json({ error: "no active Cloudflare connection for this account" }, 400);
const workerId = await deployAccountWorker(c.env, id, accessToken);
await logOperation(c.env, {
action: "account.reinstall_worker",
targetType: "account",
targetId: id,
targetName: account.name ?? null,
detail: { worker_id: workerId },
operator: await operatorName(c.env, c.get("userId")),
});
return c.json({ ok: true, worker_id: workerId });
});
app.post("/accounts/:id/rotate", async (c) => {
const id = c.req.param("id");
const account = await c.env.DB.prepare(
+6 -6
View File
@@ -432,8 +432,8 @@ export interface CFApi {
scriptName: string;
/** Console 对外地址(Worker -> Console) **/
consoleUrl: string;
/** Worker 身份(与 WorkerCredential.worker_id 一致) **/
workerId: string;
/** 凭据 token(= worker_credentials.id,一账号一个,Worker 以此表明身份) **/
credentialToken: string;
/** Worker credential secret(HMAC 签名密钥) **/
workerSecret: string;
}): Promise<{ id: string }>;
@@ -590,12 +590,12 @@ export class RealCFApi implements CFApi {
accountId: string;
scriptName: string;
consoleUrl: string;
workerId: string;
credentialToken: string;
workerSecret: string;
}) {
// Email Worker 即带 email handler 的 Worker 脚本;Console 地址与 Worker 凭据以明文常量打进脚本,
// 避免给 Worker 配置额外的 secrets 绑定(docs/11 §8:凭据本身经 HMAC 保护 Console API)。
const script = renderEmailWorkerScript(opts.consoleUrl, opts.workerId, opts.workerSecret);
// Email Worker 即带 email handler 的 Worker 脚本;Console 地址与凭据(token + secret)以明文常量
// 打进脚本,避免给 Worker 配置额外的 secrets 绑定(docs/11 §8:凭据本身经 HMAC 保护 Console API)。
const script = renderEmailWorkerScript(opts.consoleUrl, opts.credentialToken, opts.workerSecret);
const form = new FormData();
form.append(
"metadata",
+9 -8
View File
@@ -205,7 +205,7 @@ export async function provisionAccount(env: Env, input: ProvisionInput): Promise
/** # 函数
* 部署(或更新)账号级 Email Worker:
* 确保 Worker credential -> 渲染脚本(Console 地址 + Worker ID + secret)-> 上传 Cloudflare。
* 确保 Worker credential -> 渲染脚本(Console 地址 + 凭据 token(= credential id,一账号一 token)+ secret)-> 上传 Cloudflare。
* credential 已存在时复用旧 secret(重新上传脚本不轮换密钥);secret 仅在首次签发时用于部署。
*/
export async function deployAccountWorker(env: Env, accountId: string, accessToken: string): Promise<string> {
@@ -215,19 +215,19 @@ export async function deployAccountWorker(env: Env, accountId: string, accessTok
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) {
const ensured = await ensureWorkerCredential(env, accountId, workerId);
if (!ensured.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",
"SELECT id, 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 }>();
.first<{ id: string; secret_enc: string }>();
const decrypted = row ? await decryptSecret(env, row.secret_enc) : null;
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, decrypted ?? "");
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, row?.id ?? ensured.credentialId, decrypted ?? "");
return workerId;
}
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, secret);
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, ensured.credentialId, ensured.secret);
return workerId;
}
@@ -254,12 +254,13 @@ async function putWorkerScript(
accessToken: string,
cfAccountId: string,
workerId: string,
credentialToken: string,
workerSecret: string,
): Promise<void> {
const cf: CFApi = await getCFApi(env);
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 });
await cf.putEmailWorker({ accessToken, accountId: cfAccountId, scriptName: workerId, consoleUrl, credentialToken, workerSecret });
}
/** # 函数