v2.10.0: 邮件链路 401 修复 + 一账号一 token + 重装 Worker 按钮——workerAuth 同名脚本撞车时按候选逐一验签确定归属(修 itekmedia 收不到件);凭据 token(credential id)烤进 Worker 脚本、Console 按 token 精确鉴权(脚本名仅作旧版兼容);账号详情新增「重新安装 Worker 脚本」
This commit is contained in:
@@ -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) */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": "批量删除域名",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
|
||||
Reference in New Issue
Block a user