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 });
}
/** # 函数
+8 -3
View File
@@ -1,12 +1,17 @@
# Email Worker <-> Console 内部协议(docs/11 §8,协议版本 1)
`/internal/email/authorize` 与 `/internal/email/ingest` 都是内部 API,不能匿名访问。
每个请求必须满足:HTTPS + Worker ID + timestamp + nonce + body SHA256 + HMAC-SHA256。
每个请求必须满足:HTTPS + 凭据 token + timestamp + nonce + body SHA256 + HMAC-SHA256。
**身份 = 凭据 token(v2.10.0 起)**:`Authorization` 里放 `worker_credentials.id`(一账号一个,
部署 Worker 时烤进脚本),Console 按它精确定位凭据与所属账号。兼容:旧版 Worker 放的是
worker 脚本名——脚本名只在单个 Cloudflare 账号内唯一,多账号同名时 Console 会取全部同名
凭据逐一验签确定归属(v2.9.3 引入;只取一条会让先接入账号全部 401)。
## 请求头
```text
Authorization: WmailHub-Worker <worker_id>
Authorization: WmailHub-Worker <credential_token>
X-WmailHub-Timestamp: <unix_seconds>
X-WmailHub-Nonce: <random>
X-WmailHub-Body-SHA256: <hex>
@@ -24,7 +29,7 @@ signature = HMAC-SHA256(worker_credential_secret, canonical)
## Console 必须校验
1. Worker ID(存在且 credential active);
1. 凭据 token(存在且 credential active;旧脚本名按同名候选逐一验签);
2. timestamp(默认窗口 5 分钟);
3. nonce 不重复(唯一索引入库,主键冲突即 replay);
4. body hash;
+1 -1
View File
@@ -232,7 +232,7 @@ Authorization 和 Ingest 都必须是内部 API,不能匿名访问。
请求头:
Authorization: WmailHub-Worker <worker_id>
Authorization: WmailHub-Worker <credential_token> # v2.10.0 起为凭据 id(一账号一个);旧版为脚本名
X-WmailHub-Timestamp: <unix_seconds>
X-WmailHub-Nonce: <random>
X-WmailHub-Body-SHA256: <sha256>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wmailhub",
"version": "2.9.2",
"version": "2.10.0",
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
"private": true,
"type": "module",
+8 -8
View File
@@ -3,9 +3,9 @@
*
* Console 在「Connect Cloudflare Account」/ 域名配置 Email Workers 时,通过 Cloudflare API
* 把该脚本上传为账号级 Email Worker,并以占位符注入三组配置:
* __CONSOLE_URL__ Console 对外地址(Worker -> Console 的目标)
* __WORKER_ID__ Worker 身份(与 WorkerCredential.worker_id 一致)
* __WORKER_SECRET__ Worker credential secret(HMAC 签名密钥)
* __CONSOLE_URL__ Console 对外地址(Worker -> Console 的目标)
* __CREDENTIAL_TOKEN__ 凭据 token(= worker_credentials.id,一账号一个;Worker 以此表明身份)
* __WORKER_SECRET__ Worker credential secret(HMAC 签名密钥)
*
* 邮件流程:authorize(接受/拒绝/临时错误) -> accept 时 ingest 原始 MIME -> reject 时 setReject。
* worker/(本地开发版)与该模板保持同一逻辑。
@@ -14,7 +14,7 @@
export const EMAIL_WORKER_SCRIPT = String.raw`// WmailHub Email Worker (auto-generated by Console provisioning)
// Cloudflare Email Routing -> Email Worker -> WmailHub Console(docs/11)
const CONSOLE_URL = "__CONSOLE_URL__";
const WORKER_ID = "__WORKER_ID__";
const CREDENTIAL_TOKEN = "__CREDENTIAL_TOKEN__";
const WORKER_SECRET = "__WORKER_SECRET__";
export default {
@@ -82,7 +82,7 @@ async function signedHeaders(method, path, body) {
const bh = await sha256Hex(body);
const signature = await hmac(WORKER_SECRET, [method, path, timestamp, nonce, bh].join("\n"));
return {
"Authorization": "WmailHub-Worker " + WORKER_ID,
"Authorization": "WmailHub-Worker " + CREDENTIAL_TOKEN,
"X-WmailHub-Timestamp": timestamp,
"X-WmailHub-Nonce": nonce,
"X-WmailHub-Body-SHA256": bh,
@@ -169,15 +169,15 @@ async function handleEmail(env, message) {
/** # 函数
* 用实际 Console 地址与 Worker 凭据渲染 Email Worker 脚本
* 参数: consoleUrl - Console 对外地址;workerId - Worker 身份;workerSecret - credential secret
* 参数: consoleUrl - Console 对外地址;credentialToken - 凭据 token(worker_credentials.id,一账号一个);workerSecret - credential secret
* 返回值: 可直接上传 Cloudflare 的脚本字符串
*/
export function renderEmailWorkerScript(
consoleUrl: string,
workerId: string,
credentialToken: string,
workerSecret: string,
): string {
return EMAIL_WORKER_SCRIPT.replace("__CONSOLE_URL__", consoleUrl)
.replace("__WORKER_ID__", workerId)
.replace("__CREDENTIAL_TOKEN__", credentialToken)
.replace("__WORKER_SECRET__", workerSecret);
}
+8 -6
View File
@@ -8,7 +8,9 @@
* signature = HMAC-SHA256(worker_credential_secret, canonical)
*
* 请求头:
* Authorization: WmailHub-Worker <worker_id>
* Authorization: WmailHub-Worker <credential_token>
* (v2.10.0 起身份是凭据 token = worker_credentials.id,一账号一个;旧版 Worker 传脚本名,
* Console 按名取全部同名候选逐一验签兼容)
* X-WmailHub-Timestamp / X-WmailHub-Nonce / X-WmailHub-Body-SHA256 /
* X-WmailHub-Signature / X-WmailHub-Protocol
*
@@ -65,13 +67,13 @@ export async function computeSignature(secret: string, c: SignatureComponents):
/** # 函数
* Worker 端:为发往 Console 内部 API 的请求生成全部签名请求头
* 参数: secret - worker credential secret;workerId - Email Worker 身份(脚本名);
* method/url 同 HTTP 语义;body - 原始请求体
* 参数: secret - worker credential secret;token - 凭据 token(worker_credentials.id,一账号一个;
* 旧版 Worker 传脚本名,Console 兼容);method/url 同 HTTP 语义;body - 原始请求体
* 返回值: 可直接放入 fetch headers 的对象
*/
export async function signWorkerRequest(
secret: string,
workerId: string,
token: string,
method: string,
url: string,
body: Uint8Array,
@@ -88,7 +90,7 @@ export async function signWorkerRequest(
bodyHash: bh,
});
return {
"Authorization": `WmailHub-Worker ${workerId}`,
"Authorization": `WmailHub-Worker ${token}`,
"X-WmailHub-Timestamp": timestamp,
"X-WmailHub-Nonce": nonce,
"X-WmailHub-Body-SHA256": bh,
@@ -99,7 +101,7 @@ export async function signWorkerRequest(
/** # 成员变量: verifyWorkerRequest 的输入 **/
export interface VerifyWorkerRequestInput {
/** 请求方声明的 credential secret(Console 从库中按 worker_id 取出) **/
/** 请求方声明的 credential secret(Console 按请求身份(token/脚本名)取出) **/
secret: string;
/** HTTP 方法 **/
method: string;
+55 -1
View File
@@ -132,6 +132,60 @@ describe("POST /internal/email/authorize 决策矩阵", () => {
expect(((await resp.json()) as Decision).decision).toBe("accept");
});
test("同名 worker 跨账号:凭据按签名归属,不得只取最新一条(v2.9.3 回归)", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
// 第二个账号部署了与默认账号同名的 worker 脚本,凭据创建更晚
await t.env.DB.prepare(
"INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status, provision_steps, created_at, updated_at) VALUES ('acct_later', 'later-account', 'cfacct-2', ?, 'active', '{}', ?, ?)",
)
.bind(t.workerId, now, now)
.run();
const laterSecret = "whs_later_account_secret";
await t.env.DB.prepare(
"INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES ('wcred_later', 'acct_later', ?, ?, ?, ?, 'active', ?)",
)
.bind(t.workerId, await sha256Hex(laterSecret), await encryptSecret(t.env, laterSecret), laterSecret.slice(-4), now)
.run();
await t.env.DB.prepare(
"INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, created_at, updated_at) VALUES ('dom_later', 'cfacct-2', 'acct_later', 'later.test', 'active', ?, ?)",
)
.bind(now, now)
.run();
await t.env.DB.prepare(
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_later', 'dom_later', '*@later.test', 'active', 1, ?, ?)",
)
.bind(now, now)
.run();
// 先接入账号的 secret 签名(身份=脚本名,兼容路径) -> 必须验到先接入的凭据上(v2.9.3 前被最新凭据压住而 401)
const first = await authorize(t, "hello@example.test");
expect(first.status).toBe(200);
expect(first.body.decision).toBe("accept");
// 后接入账号的 secret 签名(身份=脚本名,兼容路径) -> 归属到后接入账号,它自己的域名 accept
const later = await t.app.fetch(
await signedAuthorizeRequest(t, "hello@later.test", "a@b.c", { workerId: t.workerId, secret: laterSecret }),
t.env,
);
expect(later.status).toBe(200);
expect(((await later.json()) as Decision).decision).toBe("accept");
// v2.10.0 一账号一 token:身份=凭据 id,精确匹配;token 钉死凭据,错 secret 直接 401
const tokenFirst = await t.app.fetch(
await signedAuthorizeRequest(t, "hello@example.test", "a@b.c", { workerId: "wcred1", secret: "wrong-secret" }),
t.env,
);
expect(tokenFirst.status).toBe(401);
const tokenLater = await t.app.fetch(
await signedAuthorizeRequest(t, "hello@later.test", "a@b.c", { workerId: "wcred_later", secret: laterSecret }),
t.env,
);
expect(tokenLater.status).toBe(200);
expect(((await tokenLater.json()) as Decision).decision).toBe("accept");
});
test("畸形收件人 -> reject MAILBOX_NOT_FOUND(不抛 500)", async () => {
const t = await makeTestEnv();
for (const bad of ["no-at-sign", "@example.test", "a@"]) {
@@ -170,7 +224,7 @@ function makeWorkerEnv(opts: FakeConsoleOptions): WorkerEnv {
}) as typeof fetch;
return {
CONSOLE_URL: "http://fake-console",
WORKER_ID: "wmailhub-email-test",
CREDENTIAL_TOKEN: "wcred_test_token",
WORKER_SECRET: "whs_fake_secret",
fetchImpl,
};
+1 -1
View File
@@ -140,7 +140,7 @@ export async function loginCookie(test: TestEnv): Promise<string> {
export interface SignOverrides {
/** 换用错误 secret(签名必然不匹配) **/
secret?: string;
/** 声明其它 worker_id **/
/** 请求方声明的身份(token=凭据 id,或旧版 Worker 的脚本名) **/
workerId?: string;
/** 篡改 Authorization */
tamperSignature?: boolean;
+21
View File
@@ -0,0 +1,21 @@
/** 重新安装 Worker 脚本端点测试(v2.10.0):权限/前置校验,不轮换凭据 */
import { describe, expect, test } from "bun:test";
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
describe("POST /api/accounts/:id/reinstall-worker", () => {
test("不存在的账号 -> 404", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const resp = await authedFetch(t, cookie, "http://localhost/api/accounts/acct_no_such/reinstall-worker", { method: "POST" });
expect(resp.status).toBe(404);
});
test("无 Cloudflare 连接凭据 -> 400(不触碰 Cloudflare,不留半截状态)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 种子账号有 worker_id 但没有 oauth_connections/token -> 应明确报错而非 500
const resp = await authedFetch(t, cookie, `http://localhost/api/accounts/${t.accountId}/reinstall-worker`, { method: "POST" });
expect(resp.status).toBe(400);
expect(((await resp.json()) as { error: string }).error).toContain("no active Cloudflare connection");
});
});
+13
View File
@@ -82,6 +82,19 @@ await page.waitForSelector("text=域名级 Webhook", { timeout: 8000 });
check("域名详情抽屉(Email Workers + Webhook 配置)", true);
await page.screenshot({ path: `${SHOT_DIR}/07-domain-drawer.png` });
await page.keyboard.press("Escape");
await page.waitForTimeout(400);
// 4b. 账号详情抽屉:「重新安装 Worker 脚本」按钮(v2.10.0)
await page.goto(`${BASE}/accounts`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table-row", { timeout: 8000 });
await page.locator(".ant-table-row").first().click();
await page.waitForSelector(".ant-drawer-open", { timeout: 8000 });
const hasReinstall =
(await page.locator(".ant-drawer button:has-text('重新安装 Worker 脚本')").count()) > 0;
check("账号详情抽屉含「重新安装 Worker 脚本」按钮", hasReinstall);
await page.screenshot({ path: `${SHOT_DIR}/07b-account-drawer.png` });
await page.keyboard.press("Escape");
await page.waitForTimeout(400);
// 5. 邮件详情(点击第一行)
await page.goto(`${BASE}/inboxs`, { waitUntil: "domcontentloaded" });
+2 -2
View File
@@ -4,8 +4,8 @@
export interface WorkerEnv {
/** Console 对外地址(Worker -> Console 的目标) **/
CONSOLE_URL: string;
/** Worker 身份(与 WorkerCredential.worker_id 一致) **/
WORKER_ID: string;
/** 凭据 token(= worker_credentials.id,一账号一个;Worker 以此表明身份) **/
CREDENTIAL_TOKEN: string;
/** Worker credential secret(HMAC 签名密钥) **/
WORKER_SECRET: string;
/** 测试注入:替换 fetch 实现(单测用;缺省用全局 fetch) **/
+2 -2
View File
@@ -50,7 +50,7 @@ export interface HandleEmailResult {
export async function authorizeEmail(env: WorkerEnv, recipient: string, sender: string): Promise<HandleEmailResult> {
const doFetch = env.fetchImpl ?? fetch;
const body = new TextEncoder().encode(JSON.stringify({ recipient, sender }));
const headers = await signWorkerRequest(env.WORKER_SECRET, env.WORKER_ID, "POST", `${env.CONSOLE_URL}${EMAIL_AUTHORIZE_PATH}`, body);
const headers = await signWorkerRequest(env.WORKER_SECRET, env.CREDENTIAL_TOKEN, "POST", `${env.CONSOLE_URL}${EMAIL_AUTHORIZE_PATH}`, body);
let resp: Response;
try {
resp = await doFetch(`${env.CONSOLE_URL}${EMAIL_AUTHORIZE_PATH}`, {
@@ -94,7 +94,7 @@ export async function ingestEmail(
): Promise<HandleEmailResult> {
const doFetch = env.fetchImpl ?? fetch;
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
const headers = await signWorkerRequest(env.WORKER_SECRET, env.WORKER_ID, "POST", `${env.CONSOLE_URL}${EMAIL_INGEST_PATH}`, bytes);
const headers = await signWorkerRequest(env.WORKER_SECRET, env.CREDENTIAL_TOKEN, "POST", `${env.CONSOLE_URL}${EMAIL_INGEST_PATH}`, bytes);
let resp: Response;
try {
resp = await doFetch(`${env.CONSOLE_URL}${EMAIL_INGEST_PATH}`, {
+4 -4
View File
@@ -11,12 +11,12 @@ import type { WorkerEnv } from "./env.ts";
const env: WorkerEnv = {
CONSOLE_URL: process.env.CONSOLE_URL ?? "http://127.0.0.1:18787",
WORKER_ID: process.env.WORKER_ID ?? "",
CREDENTIAL_TOKEN: process.env.CREDENTIAL_TOKEN ?? "",
WORKER_SECRET: process.env.WORKER_SECRET ?? "",
};
if (!env.WORKER_ID || !env.WORKER_SECRET) {
console.error("missing WORKER_ID / WORKER_SECRET env");
if (!env.CREDENTIAL_TOKEN || !env.WORKER_SECRET) {
console.error("missing CREDENTIAL_TOKEN / WORKER_SECRET env");
process.exit(1);
}
@@ -33,7 +33,7 @@ console.log(
lvl: "info",
msg: "email worker started",
port: PORT,
worker_id: env.WORKER_ID,
credential_token: env.CREDENTIAL_TOKEN,
console_url: env.CONSOLE_URL,
}),
);