feat: WmailHub v1.0.0 —— Cloudflare Email Workers 邮件接收与转发平台(本地可用版本)
- Console:React 19 + antd 6.6.5 前端(登录 admin/admin)+ Hono 管理 API (Nodes/Domains/Mailboxes/Emails/Webhooks/Keys/OAuth/Admin) - Node:Bun 转发节点(HMAC 签名、有限重试、心跳、结构化日志、优雅退出) - Email Worker:Cloudflare email handler + 本地联调入口 - shared:HMAC 协议(docs/04)、wmh_ Key(32 位)、AES-GCM 加密、SSRF 防护 - 邮件管线:postal-mime 解析、附件、catch-all、限额、malformed 降级、TTL 1 天 - Webhook:签名投递、指数退避重试、SSRF 校验、投递日志 - API v1 + MCP(JSON-RPC,六工具,凭据 kind 隔离) - Cloudflare OAuth:真实 + 本地 sim 模式,自动安装管线(幂等,无需手工命令) - 测试:bun test 41 例(协议安全/管线/TTL/Webhook/SSRF/MCP/OAuth/鉴权)+ Playwright UI 冒烟 - LOGO/favicon 由 codex CLI 生成
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* /api/internal/* :Node <-> Console 内部协议路由(docs/04)
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* 前三者必须通过 HMAC 签名校验 + nonce 防重放 + credential 状态检查;
|
||||
* enroll 用一次性 enrollment token(节点尚无 secret)。
|
||||
*/
|
||||
|
||||
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 { decryptSecret } from "../db.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
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);
|
||||
|
||||
const raw = new Uint8Array(await c.req.arrayBuffer());
|
||||
const secret = await decryptSecret(c.env, cred.secret_enc);
|
||||
const verdict = await verifyNodeRequest({
|
||||
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);
|
||||
|
||||
// nonce 防重放:主键冲突即拒绝
|
||||
try {
|
||||
await c.env.DB.prepare("INSERT INTO nonces (nonce, node_id, expires_at) VALUES (?, ?, ?)")
|
||||
.bind(
|
||||
verdict.nonce,
|
||||
nodeId,
|
||||
new Date((verdict.timestamp + NONCE_TTL_SECONDS) * 1000).toISOString(),
|
||||
)
|
||||
.run();
|
||||
} catch {
|
||||
return c.json({ error: "nonce replay detected" }, 401);
|
||||
}
|
||||
|
||||
// 凭据被撤销则在验签后仍然拒绝(撤销立即生效)
|
||||
if (cred.revoked_at) return c.json({ error: "credential revoked" }, 401);
|
||||
|
||||
c.set("rawBody", raw);
|
||||
c.set("nodeCredentialId", cred.id);
|
||||
await next();
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 装配 internal 路由
|
||||
*/
|
||||
export function internalRoutes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
|
||||
/** Enrollment:enrollment token 换取 Node 身份(凭证明文仅返回一次) */
|
||||
app.post("/enroll", async (c) => {
|
||||
let token = "";
|
||||
try {
|
||||
const body = (await c.req.json()) as { enrollment_token?: string };
|
||||
token = body.enrollment_token ?? "";
|
||||
} 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,
|
||||
});
|
||||
});
|
||||
|
||||
/** 邮件转发入口:message/rfc822 原文入库 */
|
||||
app.post("/nodes/:node_id/emails", nodeAuth, async (c) => {
|
||||
// nodeAuth 已确保节点存在,param 必有值
|
||||
const nodeId = c.req.param("node_id")!;
|
||||
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();
|
||||
return c.json(
|
||||
{ ok: true, id: result.id, mailbox_id: result.mailboxId, expires_at: result.expiresAt },
|
||||
202,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof IngestError) return c.json({ error: err.message }, err.status as 413);
|
||||
return c.json({ error: err instanceof Error ? err.message : "ingest failed" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/** 心跳:更新 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;
|
||||
}
|
||||
Reference in New Issue
Block a user