feat: v2.0.0 邮件接收架构改造 —— 移除 Node,Email Worker 直连 Console(docs/11)

- 两阶段接收:POST /internal/email/authorize(accept/reject/temporary_error)+ POST /internal/email/ingest(raw MIME 直传)
- Worker-Console 协议:Authorization: WmailHub-Worker + timestamp/nonce/body-hash/HMAC,nonce 防重放,凭据撤销即时生效
- 数据模型:nodes/node_credentials -> cloudflare_accounts/worker_credentials(迁移 0003),emails/domains 归属 account_id
- 接入流程 Connect Cloudflare Account:OAuth 自动同步域名 + 部署账号级 Email Worker + 签发 Worker 凭据;rotate 自动重部署
- 动态邮箱:创建/删除/禁用下一封邮件立即生效;拒绝码 DOMAIN_*/MAILBOX_*;超时/5xx 不伪装成邮箱不存在
- 删除 node/ 目录、旧 /api/internal/nodes 协议、ingress/enrollment 概念;dev.ts 简化为 console + email worker 双进程
- 前端:账号页 Email Worker/凭据管理,设置窗口移除默认 Ingress,仪表盘/文案同步;版本 2.0.0
- 测试 62 项全过(新增 authorize 决策矩阵 + Worker 两阶段临时错误语义);本机虚拟 E2E 12 场景全通
- docs 00-10 同步改写;README 更新
This commit is contained in:
bigcode
2026-09-30 13:10:26 +08:00
parent 421cfb2f04
commit fec96e49cc
56 changed files with 2584 additions and 1416 deletions
+26 -39
View File
@@ -1,11 +1,14 @@
/**
* Cloudflare OAuth 路由(docs/02):
* GET /api/oauth/cloudflare/start?node_id= 生成 state,返回授权跳转 URL
* GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动安装
* Cloudflare OAuth 路由(docs/02、docs/11 §14):
* GET /api/oauth/cloudflare/start 生成 state,返回授权跳转 URL(自动创建账号)
* GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动接入
* POST /api/oauth/cloudflare/sim/approve sim 模式模拟用户授权(仅本地)
* GET /api/oauth/connections 已建立的 OAuth 连接
*
* 安全:state 一次性(用后即删)、5 分钟过期、绑定 node_id;token AES-GCM 加密入库,
* 流程(Connect Cloudflare Account):Console -> Cloudflare OAuth -> 用户授权 ->
* Console 保存 Account + 域名同步 + 部署 Email Worker + 配置 Worker Secret;用户无需安装命令。
*
* 安全:state 一次性(用后即删)、5 分钟过期、绑定账号;token AES-GCM 加密入库,
* 不进前端与日志;Account ownership 以 token 实际可访问的 accounts 为准。
*/
@@ -14,8 +17,8 @@ import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { ids } from "../../../shared/src/index.ts";
import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi } from "../services/cfapi.ts";
import { provisionNode, createNode } from "../services/provision.ts";
import { getSetting, encryptConnectionToken } from "../db.ts";
import { provisionAccount, createAccount } from "../services/provision.ts";
import { encryptConnectionToken } from "../db.ts";
import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
@@ -23,9 +26,6 @@ type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 成员变量: OAuth state 有效期(秒) **/
const STATE_TTL_SECONDS = 300;
/** # 成员变量: 自动创建账号时的默认 Node Ingress 地址(可在设置中修改) **/
export const DEFAULT_INGRESS_URL = "http://127.0.0.1:18788/ingress/email";
/** # 函数
* 是否使用真实 Cloudflare OAuth
*/
@@ -40,7 +40,7 @@ export function oauthRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession);
/** 授权模式与入口信息(前端 Add Node 向导用) */
/** 授权模式与入口信息(前端 Connect Cloudflare Account 向导用) */
app.get("/mode", (c) => {
return c.json({
mode: isRealOAuth(c.env) ? "cloudflare" : "sim",
@@ -48,30 +48,17 @@ export function oauthRoutes(): App {
});
});
/** 开始授权:node_id 缺省时自动创建账号;创建 state 并返回跳转 URL */
/** 开始授权:每次自动创建账号记录;创建 state 并返回跳转 URL */
app.get("/cloudflare/start", async (c) => {
let nodeId = new URL(c.req.url).searchParams.get("node_id") ?? "";
if (nodeId) {
const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
.bind(nodeId)
.first<{ id: string }>();
if (!node) return c.json({ error: "account not found" }, 404);
} else {
// 添加账号:直接进入 OAuth,授权成功后自动落地账号(identity + 域名同步)
const ingressUrl =
(await getSetting(c.env, "default_ingress_url"))?.trim() || DEFAULT_INGRESS_URL;
const created = await createNode(c.env, {
name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`,
ingressUrl,
});
nodeId = created.nodeId;
}
const created = await createAccount(c.env, {
name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`,
});
const state = "cfstate_" + ids.oauthState();
await c.env.DB.prepare(
"INSERT INTO oauth_states (state, node_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
"INSERT INTO oauth_states (state, account_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
)
.bind(state, nodeId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
.bind(state, created.accountId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
.run();
if (isRealOAuth(c.env)) {
@@ -89,7 +76,7 @@ export function oauthRoutes(): App {
return c.json({ url: `/oauth/sim/authorize?state=${encodeURIComponent(state)}`, state });
});
/** 授权回调(浏览器重垒):校验 state -> 兑换 token -> 自动安装 -> 跳回控制台 */
/** 授权回调(浏览器重定向):校验 state -> 兑换 token -> 自动接入 -> 跳回控制台 */
app.get("/cloudflare/callback", async (c) => {
const url = new URL(c.req.url);
const code = url.searchParams.get("code") ?? "";
@@ -105,8 +92,8 @@ export function oauthRoutes(): App {
const token = await cf.exchangeToken(code, redirectUri);
// 加密入库(不进前端/日志)
await saveConnection(c.env, token.accessToken, token.expiresIn);
const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken });
const target = `/accounts?provision=${encodeURIComponent(verdict.nodeId)}&ok=${result.ok ? 1 : 0}`;
const result = await provisionAccount(c.env, { accountId: verdict.accountId, accessToken: token.accessToken });
const target = `/accounts?provision=${encodeURIComponent(verdict.accountId)}&ok=${result.ok ? 1 : 0}`;
return c.redirect(target, 302);
} catch (err) {
return c.text(`OAuth provisioning failed: ${err instanceof Error ? err.message : String(err)}`, 500);
@@ -121,8 +108,8 @@ export function oauthRoutes(): App {
const cf = getCFApi(c.env, c.env.DB);
const token = await cf.exchangeToken("sim-code", "urn:unused");
await saveConnection(c.env, token.accessToken, token.expiresIn);
const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken });
return c.json({ ok: result.ok, error: result.error, node_id: verdict.nodeId });
const result = await provisionAccount(c.env, { accountId: verdict.accountId, accessToken: token.accessToken });
return c.json({ ok: result.ok, error: result.error, account_id: verdict.accountId });
});
/** 已有连接列表(不含 token) */
@@ -137,20 +124,20 @@ export function oauthRoutes(): App {
}
/** # 函数
* 消费一次性 state:存在且未过期则删除并返回 node_id,否则 null(CSRF 防护核心)
* 消费一次性 state:存在且未过期则删除并返回 account_id,否则 null(CSRF 防护核心)
*/
async function consumeState(
env: Env,
state: string,
): Promise<{ nodeId: string } | null> {
): Promise<{ accountId: string } | null> {
if (!state) return null;
const row = await env.DB.prepare("SELECT node_id, expires_at FROM oauth_states WHERE state = ?")
const row = await env.DB.prepare("SELECT account_id, expires_at FROM oauth_states WHERE state = ?")
.bind(state)
.first<{ node_id: string; expires_at: string }>();
.first<{ account_id: string; expires_at: string }>();
if (!row) return null;
await env.DB.prepare("DELETE FROM oauth_states WHERE state = ?").bind(state).run();
if (row.expires_at < nowIso()) return null;
return { nodeId: row.node_id };
return { accountId: row.account_id };
}
/** # 函数