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
+3 -2
View File
@@ -28,8 +28,8 @@ export function adminRoutes(): App {
(await c.env.DB.prepare(sql).first<{ n: number }>())?.n ?? 0;
const now = nowIso();
const stats = {
accounts_online: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status = 'online'`),
accounts_total: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'`),
accounts_total: await count(`SELECT COUNT(*) AS n FROM cloudflare_accounts WHERE status != 'revoked'`),
accounts_active: await count(`SELECT COUNT(*) AS n FROM cloudflare_accounts WHERE status = 'active'`),
domains: await count("SELECT COUNT(*) AS n FROM domains"),
domains_active: await count(`SELECT COUNT(*) AS n FROM domains WHERE status = 'active'`),
mailboxes: await count("SELECT COUNT(*) AS n FROM mailboxes"),
@@ -42,6 +42,7 @@ export function adminRoutes(): App {
`SELECT COUNT(*) AS n FROM webhook_deliveries WHERE status IN ('pending','failed')`,
),
oauth_connections: await count("SELECT COUNT(*) AS n FROM oauth_connections"),
worker_credentials: await count("SELECT COUNT(*) AS n FROM worker_credentials WHERE status = 'active'"),
};
const recent = await c.env.DB.prepare(
`SELECT id, subject, from_address, received_at, size FROM emails
+5 -5
View File
@@ -18,11 +18,11 @@ export function apiV1Routes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireApiKey("api"));
/** 节点列表 */
app.get("/nodes", async (c) => {
/** 账号列表 */
app.get("/accounts", async (c) => {
const rows = await c.env.DB.prepare(
`SELECT id, name, cloudflare_account_id, status, version, last_seen_at, created_at
FROM nodes WHERE status != 'revoked' ORDER BY created_at DESC`,
`SELECT id, name, cloudflare_account_id, worker_id, status, created_at
FROM cloudflare_accounts WHERE status != 'revoked' ORDER BY created_at DESC`,
).all();
return c.json({ items: rows.results });
});
@@ -30,7 +30,7 @@ export function apiV1Routes(): App {
/** 域名列表 */
app.get("/domains", async (c) => {
const rows = await c.env.DB.prepare(
"SELECT id, name, status, node_id, created_at FROM domains ORDER BY created_at DESC",
"SELECT id, name, status, account_id, created_at FROM domains ORDER BY created_at DESC",
).all();
return c.json({ items: rows.results });
});
+143 -83
View File
@@ -1,21 +1,19 @@
/**
* /api/internal/* :Node <-> Console 内部协议路由(docs/04)
* /internal/email/* :Email Worker <-> Console 内部协议(docs/11 §7/§8/§9)
*
* POST /api/internal/nodes/{node_id}/emails 邮件转发(message/rfc822)
* POST /api/internal/nodes/{node_id}/heartbeat 心跳
* GET /api/internal/nodes/{node_id}/config 配置拉取
* POST /api/internal/enroll bootstrap enrollment
* POST /internal/email/authorize SMTP 阶段决策:accept | reject | temporary_error
* POST /internal/email/ingest 原始 MIME 入库(D1 + R2 + Webhook),仅 accept 后调用
*
* 前三者必须通过 HMAC 签名校验 + nonce 防重放 + credential 状态检查;
* enroll 用一次性 enrollment token(节点尚无 secret)。
* 两者都必须通过 HMAC 签名校验 + nonce 防重放 + credential/账号状态检查(docs/11 §8):
* Console 校验 Worker ID、timestamp、nonce、body hash、HMAC、credential 状态,
* 并确认请求 Worker 所属 Cloudflare 账号与收件域名一致。
*/
import { Hono } from "hono";
import type { Context, Next } from "hono";
import type { Env, AppVars } from "../env.ts";
import { verifyNodeRequest } from "../../../shared/src/index.ts";
import { ingestEmail, IngestError } from "../services/email_ingest.ts";
import { enrollNode } from "../services/provision.ts";
import { verifyWorkerRequest } from "../../../shared/src/index.ts";
import { ingestEmail, IngestError, resolveMailbox } from "../services/email_ingest.ts";
import { decryptSecret } from "../db.ts";
import { nowIso } from "../util.ts";
@@ -25,22 +23,44 @@ type Ctx = Context<{ Bindings: Env; Variables: AppVars }>;
/** # 成员变量: nonce 防重放窗口(与 timestamp 窗口一致) **/
const NONCE_TTL_SECONDS = 600;
/** # 函数
* HMAC 校验中间件:验签、时间窗、body hash、nonce 唯一性、credential 状态。
* 原始请求体缓存到 c.var.rawBody 供路由处理器复用。
*/
async function nodeAuth(c: Ctx, next: Next) {
const nodeId = c.req.param("node_id");
const cred = await c.env.DB.prepare(
"SELECT id, secret_enc, status, revoked_at FROM node_credentials WHERE node_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
)
.bind(nodeId)
.first<{ id: string; secret_enc: string; status: string; revoked_at: string | null }>();
if (!cred) return c.json({ error: "unknown node or credential revoked" }, 401);
/** # 成员变量: reject 状态码与原因(docs/11 §7;reason 不暴露内部信息) **/
const REJECT_REASONS: Record<string, string> = {
DOMAIN_NOT_FOUND: "Mailbox does not exist",
DOMAIN_DISABLED: "Receiving for this domain is not enabled",
MAILBOX_NOT_FOUND: "Mailbox does not exist",
MAILBOX_DISABLED: "Mailbox is disabled",
RECEIVING_DISABLED: "Receiving is disabled",
};
/** # 成员变量: authorize 请求体 **/
interface AuthorizeBody {
recipient?: string;
sender?: string;
message_id?: string;
}
/** # 成员变量: 决策结果 **/
export type EmailDecision =
| { decision: "accept" }
| { decision: "reject"; code: string; reason: string };
/** # 函数
* HMAC 校验中间件:验签、时间窗、body hash、nonce 唯一性、credential/账号状态。
* 原始请求体缓存到 c.var.rawBody 供路由处理器复用;workerAccountId 记录归属账号。
*/
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 verifyNodeRequest({
const verdict = await verifyWorkerRequest({
secret,
method: c.req.method,
url: c.req.url,
@@ -51,10 +71,10 @@ async function nodeAuth(c: Ctx, next: Next) {
// nonce 防重放:主键冲突即拒绝
try {
await c.env.DB.prepare("INSERT INTO nonces (nonce, node_id, expires_at) VALUES (?, ?, ?)")
await c.env.DB.prepare("INSERT INTO nonces (nonce, account_id, expires_at) VALUES (?, ?, ?)")
.bind(
verdict.nonce,
nodeId,
cred.cloudflare_account_id,
new Date((verdict.timestamp + NONCE_TTL_SECONDS) * 1000).toISOString(),
)
.run();
@@ -62,54 +82,122 @@ async function nodeAuth(c: Ctx, next: Next) {
return c.json({ error: "nonce replay detected" }, 401);
}
// 凭据被撤销则在验签后仍然拒绝(撤销立即生效)
if (cred.revoked_at) return c.json({ error: "credential revoked" }, 401);
// 撤销立即生效:credential/账号状态在验签后复查
const fresh = await c.env.DB.prepare(
`SELECT wc.status AS cred_status, ca.status AS account_status
FROM worker_credentials wc JOIN cloudflare_accounts ca ON ca.id = wc.cloudflare_account_id
WHERE wc.id = ?`,
)
.bind(cred.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("nodeCredentialId", cred.id);
c.set("workerCredentialId", cred.id);
c.set("workerAccountId", cred.cloudflare_account_id);
await next();
}
/** # 函数
* 装配 internal 路由
* 收件人决策逻辑(docs/11 §7):域名归属 -> 域名状态 -> 邮箱精确/ catch-all -> 邮箱状态。
* 返回明确的 decision,绝不把 5xx/超时解释成邮箱不存在(那由 Worker 层处理)。
*/
export async function decideRecipient(
env: Env,
accountId: string,
recipient: string,
): Promise<EmailDecision> {
const addr = recipient.trim().toLowerCase();
const at = addr.lastIndexOf("@");
if (at <= 0 || at === addr.length - 1) {
return { decision: "reject", code: "MAILBOX_NOT_FOUND", reason: REJECT_REASONS.MAILBOX_NOT_FOUND };
}
const domainName = addr.slice(at + 1);
const domain = await env.DB.prepare(
"SELECT id, status FROM domains WHERE name = ? AND account_id = ?",
)
.bind(domainName, accountId)
.first<{ id: string; status: string }>();
if (!domain) {
return { decision: "reject", code: "DOMAIN_NOT_FOUND", reason: REJECT_REASONS.DOMAIN_NOT_FOUND };
}
if (domain.status !== "active") {
return { decision: "reject", code: "DOMAIN_DISABLED", reason: REJECT_REASONS.DOMAIN_DISABLED };
}
// 邮箱:精确匹配优先,其次 catch-all;disabled 一律拒绝
const exact = await env.DB.prepare(
"SELECT id, status FROM mailboxes WHERE address = ? AND domain_id = ?",
)
.bind(addr, domain.id)
.first<{ id: string; status: string }>();
if (exact) {
if (exact.status !== "active") {
return { decision: "reject", code: "MAILBOX_DISABLED", reason: REJECT_REASONS.MAILBOX_DISABLED };
}
return { decision: "accept" };
}
const catchAll = await env.DB.prepare(
"SELECT id, status FROM mailboxes WHERE domain_id = ? AND is_catch_all = 1 LIMIT 1",
)
.bind(domain.id)
.first<{ id: string; status: string }>();
if (catchAll) {
if (catchAll.status !== "active") {
return { decision: "reject", code: "MAILBOX_DISABLED", reason: REJECT_REASONS.MAILBOX_DISABLED };
}
return { decision: "accept" };
}
return { decision: "reject", code: "MAILBOX_NOT_FOUND", reason: REJECT_REASONS.MAILBOX_NOT_FOUND };
}
/** # 函数
* 装配 Email Worker 内部路由
*/
export function internalRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
/** Enrollment:enrollment token 换取 Node 身份(凭证明文仅返回一次) */
app.post("/enroll", async (c) => {
let token = "";
/** 第一阶段:SMTP 决策(不保存邮件) */
app.post("/email/authorize", workerAuth, async (c) => {
const accountId = c.get("workerAccountId")!;
let body: AuthorizeBody;
try {
const body = (await c.req.json()) as { enrollment_token?: string };
token = body.enrollment_token ?? "";
body = JSON.parse(new TextDecoder().decode(c.get("rawBody") ?? new Uint8Array())) as AuthorizeBody;
} catch {
return c.json({ error: "invalid body" }, 400);
}
if (!token.startsWith("wenr_")) return c.json({ error: "invalid enrollment token" }, 401);
const result = await enrollNode(c.env, token);
if (!result) return c.json({ error: "invalid or expired enrollment token" }, 401);
return c.json({
ok: true,
node_id: result.node_id,
credential_id: result.credential_id,
credential_secret: result.credential_secret,
ingress_secret: result.ingress_secret,
});
const recipient = (body.recipient ?? "").trim();
if (!recipient) return c.json({ error: "recipient required" }, 400);
const verdict = await decideRecipient(c.env, accountId, recipient);
// 未过期时记录 message_id 便于追踪(仅日志,不入库)
if (body.message_id) {
console.log(
JSON.stringify({ t: nowIso(), msg: "email.authorize", decision: verdict.decision, recipient, message_id: body.message_id }),
);
}
return c.json(verdict);
});
/** 邮件转发入口:message/rfc822 原文入库 */
app.post("/nodes/:node_id/emails", nodeAuth, async (c) => {
// nodeAuth 已确保节点存在,param 必有值
const nodeId = c.req.param("node_id")!;
/** 第二阶段:raw MIME 入库(message/rfc822;收件人经信封头传递) */
app.post("/email/ingest", workerAuth, async (c) => {
const accountId = c.get("workerAccountId")!;
const recipient = (c.req.header("X-WmailHub-Recipient") ?? "").trim();
const sender = (c.req.header("X-WmailHub-Sender") ?? "").trim();
if (!recipient) return c.json({ error: "missing X-WmailHub-Recipient" }, 400);
// 动态邮箱:authorize 与 ingest 之间状态可能变化,再验一次收件人
const verdict = await decideRecipient(c.env, accountId, recipient);
if (verdict.decision === "reject") {
return c.json({ error: "recipient no longer accepted", code: verdict.code }, 409);
}
// 邮箱关联兜底:authorize 已确认可接收,此处解析 mailbox(精确/catch-all)
const mailboxId = await resolveMailbox(c.env, [recipient]);
const raw = c.get("rawBody") ?? new Uint8Array();
const mailFrom = c.req.header("X-WmailHub-Mail-From") ?? "";
const rcptTo = c.req.header("X-WmailHub-Rcpt-To") ?? "";
try {
const result = await ingestEmail(c.env, { raw, mailFrom, rcptTo, nodeId });
// 更新节点可见性
await c.env.DB.prepare("UPDATE nodes SET last_seen_at = ?, updated_at = ? WHERE id = ?")
.bind(nowIso(), nowIso(), nodeId)
.run();
const result = await ingestEmail(c.env, { raw, mailFrom: sender, rcptTo: recipient, accountId });
return c.json(
{ ok: true, id: result.id, mailbox_id: result.mailboxId, expires_at: result.expiresAt },
202,
@@ -120,33 +208,5 @@ export function internalRoutes(): App {
}
});
/** 心跳:更新 last_seen_at / version / status=online */
app.post("/nodes/:node_id/heartbeat", nodeAuth, async (c) => {
const nodeId = c.req.param("node_id");
let version: string | null = null;
try {
const body = c.get("rawBody") ?? new Uint8Array();
const parsed = JSON.parse(new TextDecoder().decode(body)) as { version?: string };
version = parsed.version ?? null;
} catch {
// 空 body 合法
}
await c.env.DB.prepare(
"UPDATE nodes SET last_seen_at = ?, status = 'online', version = COALESCE(?, version), updated_at = ? WHERE id = ?",
)
.bind(nowIso(), version, nowIso(), nodeId)
.run();
return c.json({ ok: true });
});
/** 配置拉取:协议版本与默认保留时长 */
app.get("/nodes/:node_id/config", nodeAuth, (c) => {
return c.json({
protocol_version: 1,
default_retention_seconds: 86_400,
server_time: nowIso(),
});
});
return app;
}
+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 };
}
/** # 函数
+84 -59
View File
@@ -1,9 +1,10 @@
/**
* Console 管理路由(Account(账号) / Domain / Mailbox / Email)。
* 全部要求会话登录。v1.1.0:
* - 节点在管理面更名「账号」,路由 /api/accounts(内部 Node 协议 /api/internal/nodes 不变)
* 全部要求会话登录。v2.0.0:
* - 邮件链路改为 Email Worker -> Console(docs/11);账号 = 已连接的 Cloudflare 账户
* - 删除手工添加域名;域名由 Cloudflare OAuth 自动同步
* - POST /domains/:id/setup 为域名配置 Email Workers(Worker + Routing + catch-all)
* - POST /accounts/:id/rotate 轮换账号的 Worker credential(secret 仅一次可见)
* - 邮箱/域名级 Webhook 配置(PUT/DELETE .../webhook)
*/
@@ -11,8 +12,8 @@ import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { loadSteps, setupDomainEmail, upsertMailbox } from "../services/provision.ts";
import { decryptSecret } from "../db.ts";
import { checkWebhookUrl } from "../../../shared/src/index.ts";
import { encryptSecret, keyHash, decryptConnectionToken, decryptSecret } from "../db.ts";
import { checkWebhookUrl, ids } from "../../../shared/src/index.ts";
import { normalizeWebhookHeaders, saveScopedWebhook } from "../services/webhook_delivery.ts";
import { nowIso, pageParams, readJson } from "../util.ts";
@@ -63,23 +64,21 @@ function installAccountRoutes(app: App) {
app.get("/accounts", async (c) => {
const { limit, offset } = pageParams(new URL(c.req.url));
const rows = await c.env.DB.prepare(
`SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at,
ingress_url, created_at, updated_at
FROM nodes WHERE status != 'revoked' ORDER BY created_at DESC LIMIT ? OFFSET ?`,
`SELECT id, name, cloudflare_account_id, worker_id, status, created_at, updated_at
FROM cloudflare_accounts WHERE status != 'revoked' ORDER BY created_at DESC LIMIT ? OFFSET ?`,
)
.bind(limit, offset)
.all();
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'").first<{ n: number }>();
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM cloudflare_accounts WHERE status != 'revoked'").first<{ n: number }>();
return c.json({ items: rows.results, total: total?.n ?? 0 });
});
/** 账号详情:含安装步骤状态与 OAuth 连接状态 */
/** 账号详情:含接入步骤状态、OAuth 连接状态与 Worker credential 信息 */
app.get("/accounts/:id", async (c) => {
const id = c.req.param("id");
const account = await c.env.DB.prepare(
`SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at,
ingress_url, enrollment_expires_at, created_at, updated_at
FROM nodes WHERE id = ?`,
`SELECT id, name, cloudflare_account_id, worker_id, status, created_at, updated_at
FROM cloudflare_accounts WHERE id = ?`,
)
.bind(id)
.first();
@@ -88,92 +87,118 @@ function installAccountRoutes(app: App) {
const oauth = await c.env.DB.prepare(
"SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC LIMIT 1",
).first();
const domains = await c.env.DB.prepare("SELECT id, name, status, worker_id FROM domains WHERE node_id = ?")
const domains = await c.env.DB.prepare("SELECT id, name, status, worker_id FROM domains WHERE account_id = ?")
.bind(id)
.all();
return c.json({ account, steps, oauth: oauth ?? null, domains: domains.results });
const credential = await c.env.DB.prepare(
"SELECT id, worker_id, secret_last4, status, created_at FROM worker_credentials WHERE cloudflare_account_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
)
.bind(id)
.first();
return c.json({ account, steps, oauth: oauth ?? null, domains: domains.results, credential: credential ?? null });
});
/** 更新账号:名称 / Node Ingress 地址(Email Worker 回源地址) */
/** 更新账号:名称 */
app.patch("/accounts/:id", async (c) => {
const id = c.req.param("id");
const body = await readJson<{ name?: string; ingress_url?: string }>(c.req.raw);
const account = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
const body = await readJson<{ name?: string }>(c.req.raw);
const account = await c.env.DB.prepare("SELECT id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'")
.bind(id)
.first();
if (!account) return c.json({ error: "not found" }, 404);
if (body.name !== undefined) {
const name = body.name.trim();
if (!name) return c.json({ error: "name required" }, 400);
await c.env.DB.prepare("UPDATE nodes SET name = ?, updated_at = ? WHERE id = ?").bind(name, nowIso(), id).run();
}
if (body.ingress_url !== undefined) {
const ingressUrl = body.ingress_url.trim();
try {
new URL(ingressUrl);
} catch {
return c.json({ error: "invalid ingress_url" }, 400);
}
await c.env.DB.prepare("UPDATE nodes SET ingress_url = ?, updated_at = ? WHERE id = ?")
.bind(ingressUrl, nowIso(), id)
.run();
await c.env.DB.prepare("UPDATE cloudflare_accounts SET name = ?, updated_at = ? WHERE id = ?").bind(name, nowIso(), id).run();
}
return c.json({ ok: true });
});
/** 删除账号(软删:撤销 credential + 标记 revoked) */
/** 删除账号(软删:撤销 Worker credential + 标记 revoked) */
app.delete("/accounts/:id", async (c) => {
const id = c.req.param("id");
await c.env.DB.prepare(
"UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'",
"UPDATE worker_credentials SET status = 'revoked', revoked_at = ? WHERE cloudflare_account_id = ? AND status = 'active'",
)
.bind(nowIso(), id)
.run();
await c.env.DB.prepare("UPDATE nodes SET status = 'revoked', updated_at = ? WHERE id = ?")
await c.env.DB.prepare("UPDATE cloudflare_accounts SET status = 'revoked', updated_at = ? WHERE id = ?")
.bind(nowIso(), id)
.run();
return c.json({ ok: true });
});
/** 重新生成 enrollment token(credential 轮换入口) */
/** 轮换 Worker credential:撤销旧 credential 并签发新 secret(仅本次响应可见);
* 已配置 Email Workers 的域名自动重新部署脚本以携带新 secret。 */
app.post("/accounts/:id/rotate", async (c) => {
const id = c.req.param("id");
const account = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
.bind(id)
.first();
if (!account) return c.json({ error: "not found" }, 404);
const token = "wenr_" + crypto.randomUUID().replace(/-/g, "");
await c.env.DB.prepare(
"UPDATE nodes SET enrollment_token = ?, enrollment_expires_at = ?, updated_at = ? WHERE id = ?",
const account = await c.env.DB.prepare(
"SELECT id, worker_id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'",
)
.bind(token, new Date(Date.now() + 24 * 3600 * 1000).toISOString(), nowIso(), id)
.run();
return c.json({ ok: true, enrollment_token: token });
});
/** 本地/运维用途:查看 ingress secret(会话保护;真实部署由 provisioning 自动注入 Worker) */
app.get("/accounts/:id/ingress-secret", async (c) => {
const id = c.req.param("id");
const account = await c.env.DB.prepare("SELECT ingress_secret_enc FROM nodes WHERE id = ?")
.bind(id)
.first<{ ingress_secret_enc: string }>();
.first<{ id: string; worker_id: string | null }>();
if (!account) return c.json({ error: "not found" }, 404);
return c.json({ ingress_secret: await decryptSecret(c.env, account.ingress_secret_enc) });
if (!account.worker_id) return c.json({ error: "worker not provisioned yet" }, 400);
const accessToken = await decryptConnectionToken(c.env);
if (!accessToken) return c.json({ error: "no active Cloudflare OAuth connection" }, 400);
// 轮换:撤销旧 credential,签发新 credential
await c.env.DB.prepare(
"UPDATE worker_credentials SET status = 'revoked', revoked_at = ? WHERE cloudflare_account_id = ? AND status = 'active'",
)
.bind(nowIso(), id)
.run();
const secret = "whs_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
const credentialId = ids.credential();
await c.env.DB.prepare(
"INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES (?, ?, ?, ?, ?, ?, 'active', ?)",
)
.bind(
credentialId,
id,
account.worker_id,
await keyHash(secret),
await encryptSecret(c.env, secret),
secret.slice(-4),
nowIso(),
)
.run();
// 已启用 Email Workers 的域名重新部署脚本(幂等)
const domains = await c.env.DB.prepare(
"SELECT id, name FROM domains WHERE account_id = ? AND worker_id IS NOT NULL",
)
.bind(id)
.all<{ id: string; name: string }>();
const redeployed: string[] = [];
for (const d of domains.results) {
const r = await setupDomainEmail(c.env, d.id);
if (r.ok) redeployed.push(d.name);
}
return c.json({ ok: true, worker_id: account.worker_id, credential_id: credentialId, secret, redeployed });
});
/** 本地/运维用途:查看账号 Worker credential(会话保护;真实部署由 provisioning 自动注入 Worker) */
app.get("/accounts/:id/worker-secret", async (c) => {
const id = c.req.param("id");
const cred = await c.env.DB.prepare(
"SELECT worker_id, secret_enc FROM worker_credentials WHERE cloudflare_account_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
)
.bind(id)
.first<{ worker_id: string; secret_enc: string }>();
if (!cred) return c.json({ error: "not found" }, 404);
return c.json({ worker_id: cred.worker_id, secret: await decryptSecret(c.env, cred.secret_enc) });
});
}
/** # 函数
* Domain 查询 / Email Workers 配置 / Webhook 配置
*/
function installDomainRoutes(app: App) {
app.get("/domains", async (c) => {
const { limit, offset } = pageParams(new URL(c.req.url));
const rows = await c.env.DB.prepare(
`SELECT d.id, d.name, d.status, d.setup_error, d.worker_id, d.zone_id, d.node_id,
`SELECT d.id, d.name, d.status, d.setup_error, d.worker_id, d.zone_id, d.account_id,
d.webhook_url, d.webhook_headers, (d.webhook_key_enc IS NOT NULL) AS webhook_has_key,
n.name AS node_name,
a.name AS account_name,
(SELECT COUNT(*) FROM mailboxes m WHERE m.domain_id = d.id) AS mailbox_count
FROM domains d LEFT JOIN nodes n ON n.id = d.node_id
FROM domains d LEFT JOIN cloudflare_accounts a ON a.id = d.account_id
ORDER BY d.created_at DESC LIMIT ? OFFSET ?`,
)
.bind(limit, offset)
@@ -185,7 +210,7 @@ function installDomainRoutes(app: App) {
/** 域名可用的账号(供筛选) */
app.get("/domains/filters/accounts", async (c) => {
const rows = await c.env.DB.prepare(
"SELECT DISTINCT n.id, n.name FROM nodes n JOIN domains d ON d.node_id = n.id ORDER BY n.name",
"SELECT DISTINCT a.id, a.name FROM cloudflare_accounts a JOIN domains d ON d.account_id = a.id ORDER BY a.name",
).all();
return c.json({ items: rows.results });
});
+5 -21
View File
@@ -1,7 +1,7 @@
/**
* 设置路由(v1.1.0):
* GET /api/settings 读取:全局 Webhook / 默认 Node Ingress 地址 / 当前用户
* PUT /api/settings 保存:全局 Webhook + 默认 Node Ingress 地址
* 设置路由(v2.0.0):
* GET /api/settings 读取:全局 Webhook / 当前用户
* PUT /api/settings 保存:全局 Webhook
* PUT /api/settings/password 修改登录密码
* API/MCP Key 管理沿用 /api/keys(前端设置模态窗口内调用)。
*/
@@ -10,7 +10,7 @@ import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { checkWebhookUrl, hashPassword, verifyPassword } from "../../../shared/src/index.ts";
import { getSetting, setSetting } from "../db.ts";
import { getSetting } from "../db.ts";
import {
getGlobalWebhookKey,
normalizeWebhookHeaders,
@@ -49,32 +49,16 @@ export function settingsRoutes(): App {
}
return c.json({
username: user?.username ?? "",
default_ingress_url: (await getSetting(c.env, "default_ingress_url")) ?? "",
webhook,
});
});
/** 保存设置:全局 Webhook + 默认 Node Ingress 地址 */
/** 保存设置:全局 Webhook */
app.put("/", async (c) => {
const body = await readJson<{
default_ingress_url?: string;
webhook?: { url?: string; headers?: unknown; key?: string };
}>(c.req.raw);
if (body.default_ingress_url !== undefined) {
const ingressUrl = body.default_ingress_url.trim();
if (ingressUrl) {
try {
new URL(ingressUrl);
} catch {
return c.json({ error: "invalid default_ingress_url" }, 400);
}
await setSetting(c.env, "default_ingress_url", ingressUrl);
} else {
await c.env.DB.prepare("DELETE FROM settings WHERE key = 'default_ingress_url'").run();
}
}
if (body.webhook) {
const url = (body.webhook.url ?? "").trim();
let headers: Record<string, string>;