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,16 +50,37 @@ 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
|
||||
// 一个账号一个 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.worker_id = ? AND wc.status = 'active' ORDER BY wc.created_at DESC LIMIT 1`,
|
||||
WHERE wc.id = ? AND wc.status = 'active' AND ca.status != 'revoked'`,
|
||||
)
|
||||
.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);
|
||||
.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,
|
||||
@@ -67,15 +88,22 @@ async function workerAuth(c: Ctx, next: Next) {
|
||||
body: raw,
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
if (!verdict.ok) return c.json({ error: verdict.error }, verdict.status);
|
||||
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 });
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Console 在「Connect Cloudflare Account」/ 域名配置 Email Workers 时,通过 Cloudflare API
|
||||
* 把该脚本上传为账号级 Email Worker,并以占位符注入三组配置:
|
||||
* __CONSOLE_URL__ Console 对外地址(Worker -> Console 的目标)
|
||||
* __WORKER_ID__ Worker 身份(与 WorkerCredential.worker_id 一致)
|
||||
* __CREDENTIAL_TOKEN__ 凭据 token(= worker_credentials.id,一账号一个;Worker 以此表明身份)
|
||||
* __WORKER_SECRET__ Worker credential secret(HMAC 签名密钥)
|
||||
*
|
||||
* 邮件流程:authorize(接受/拒绝/临时错误) -> accept 时 ingest 原始 MIME -> reject 时 setReject。
|
||||
@@ -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,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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user