- 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 生成
363 lines
15 KiB
TypeScript
363 lines
15 KiB
TypeScript
/**
|
|
* Console 管理路由(Node / Domain / Mailbox / Email)。
|
|
* 全部要求会话登录。
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import type { Env, AppVars } from "../env.ts";
|
|
import { requireSession } from "../middleware.ts";
|
|
import { createNode, emailWorkerNameFor, loadSteps } from "../services/provision.ts";
|
|
import { upsertMailbox, upsertDomain } from "../services/provision.ts";
|
|
import { decryptSecret } from "../db.ts";
|
|
import { ids } from "../../../shared/src/index.ts";
|
|
import { checkWebhookUrl } from "../../../shared/src/index.ts";
|
|
import { nowIso, pageParams, readJson } from "../util.ts";
|
|
|
|
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
|
|
|
/** # 函数
|
|
* 装配 Node / Domain / Mailbox / Email 管理路由
|
|
*/
|
|
export function resourceRoutes(): App {
|
|
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
|
app.use("*", requireSession);
|
|
|
|
installNodeRoutes(app);
|
|
installDomainRoutes(app);
|
|
installMailboxRoutes(app);
|
|
installEmailRoutes(app);
|
|
return app;
|
|
}
|
|
|
|
/** # 函数
|
|
* Node CRUD 与安装状态
|
|
*/
|
|
function installNodeRoutes(app: App) {
|
|
/** Node 列表(不含任何 secret) */
|
|
app.get("/nodes", 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 ?`,
|
|
)
|
|
.bind(limit, offset)
|
|
.all();
|
|
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'").first<{ n: number }>();
|
|
return c.json({ items: rows.results, total: total?.n ?? 0 });
|
|
});
|
|
|
|
/** Add Node:创建节点,返回 enrollment token(仅此一次展示)与 OAuth 授权入口信息 */
|
|
app.post("/nodes", async (c) => {
|
|
const body = await readJson<{ name?: string; ingress_url?: string }>(c.req.raw);
|
|
const name = (body.name ?? "").trim();
|
|
if (!name) return c.json({ error: "name required" }, 400);
|
|
const ingressUrl = (body.ingress_url ?? "http://127.0.0.1:8788/ingress/email").trim();
|
|
try {
|
|
new URL(ingressUrl);
|
|
} catch {
|
|
return c.json({ error: "invalid ingress_url" }, 400);
|
|
}
|
|
const created = await createNode(c.env, { name, ingressUrl });
|
|
return c.json({
|
|
ok: true,
|
|
node_id: created.nodeId,
|
|
enrollment_token: created.enrollmentToken,
|
|
ingress_secret: created.ingressSecret,
|
|
worker_name: emailWorkerNameFor(created.nodeId),
|
|
}, 201);
|
|
});
|
|
|
|
/** Node 详情:含安装步骤状态与 OAuth 连接状态 */
|
|
app.get("/nodes/:id", async (c) => {
|
|
const id = c.req.param("id");
|
|
const node = 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 = ?`,
|
|
)
|
|
.bind(id)
|
|
.first();
|
|
if (!node) return c.json({ error: "not found" }, 404);
|
|
const steps = await loadSteps(c.env, id);
|
|
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 = ?")
|
|
.bind(id)
|
|
.all();
|
|
return c.json({ node, steps, oauth: oauth ?? null, domains: domains.results });
|
|
});
|
|
|
|
/** 删除 Node(软删:撤销 credential + 标记 revoked) */
|
|
app.delete("/nodes/: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'",
|
|
)
|
|
.bind(nowIso(), id)
|
|
.run();
|
|
await c.env.DB.prepare("UPDATE nodes SET status = 'revoked', updated_at = ? WHERE id = ?")
|
|
.bind(nowIso(), id)
|
|
.run();
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
/** 重新生成 enrollment token(credential 轮换入口) */
|
|
app.post("/nodes/:id/rotate", async (c) => {
|
|
const id = c.req.param("id");
|
|
const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
|
|
.bind(id)
|
|
.first();
|
|
if (!node) 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 = ?",
|
|
)
|
|
.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("/nodes/:id/ingress-secret", async (c) => {
|
|
const id = c.req.param("id");
|
|
const node = await c.env.DB.prepare("SELECT ingress_secret_enc FROM nodes WHERE id = ?")
|
|
.bind(id)
|
|
.first<{ ingress_secret_enc: string }>();
|
|
if (!node) return c.json({ error: "not found" }, 404);
|
|
return c.json({ ingress_secret: await decryptSecret(c.env, node.ingress_secret_enc) });
|
|
});
|
|
}
|
|
|
|
/** # 函数
|
|
* Domain CRUD
|
|
*/
|
|
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.*, n.name AS node_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
|
|
ORDER BY d.created_at DESC LIMIT ? OFFSET ?`,
|
|
)
|
|
.bind(limit, offset)
|
|
.all();
|
|
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM domains").first<{ n: number }>();
|
|
return c.json({ items: rows.results, total: total?.n ?? 0 });
|
|
});
|
|
|
|
/** 手工添加 Domain(供无 OAuth 场景;OAuth 安装自动创建) */
|
|
app.post("/domains", async (c) => {
|
|
const body = await readJson<{ name?: string; node_id?: string; cloudflare_account_id?: string }>(c.req.raw);
|
|
const name = (body.name ?? "").trim().toLowerCase();
|
|
if (!name || !name.includes(".")) return c.json({ error: "invalid domain name" }, 400);
|
|
const id = await upsertDomain(c.env, {
|
|
cloudflareAccountId: body.cloudflare_account_id ?? "manual",
|
|
nodeId: body.node_id ?? null as unknown as string,
|
|
name,
|
|
zoneId: "manual",
|
|
workerId: null as unknown as string,
|
|
});
|
|
return c.json({ ok: true, id }, 201);
|
|
});
|
|
|
|
app.delete("/domains/:id", async (c) => {
|
|
const id = c.req.param("id");
|
|
const used = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes WHERE domain_id = ?")
|
|
.bind(id)
|
|
.first<{ n: number }>();
|
|
if ((used?.n ?? 0) > 0) return c.json({ error: "domain has mailboxes" }, 409);
|
|
await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run();
|
|
return c.json({ ok: true });
|
|
});
|
|
}
|
|
|
|
/** # 函数
|
|
* Mailbox CRUD
|
|
*/
|
|
function installMailboxRoutes(app: App) {
|
|
app.get("/mailboxes", async (c) => {
|
|
const { limit, offset } = pageParams(new URL(c.req.url));
|
|
const domainId = new URL(c.req.url).searchParams.get("domain_id");
|
|
const where = domainId ? "WHERE m.domain_id = ?" : "";
|
|
const rows = await c.env.DB.prepare(
|
|
`SELECT m.*, d.name AS domain_name,
|
|
(SELECT COUNT(*) FROM emails e WHERE e.mailbox_id = m.id) AS email_count
|
|
FROM mailboxes m JOIN domains d ON d.id = m.domain_id
|
|
${where} ORDER BY m.created_at DESC LIMIT ? OFFSET ?`,
|
|
);
|
|
const bound = domainId ? rows.bind(domainId, limit, offset) : rows.bind(limit, offset);
|
|
const res = await bound.all();
|
|
const totalRows = domainId
|
|
? await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes WHERE domain_id = ?").bind(domainId).first<{ n: number }>()
|
|
: await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes").first<{ n: number }>();
|
|
return c.json({ items: res.results, total: totalRows?.n ?? 0 });
|
|
});
|
|
|
|
app.post("/mailboxes", async (c) => {
|
|
const body = await readJson<{ domain_id?: string; address?: string; retention_seconds?: number; catch_all?: boolean }>(c.req.raw);
|
|
if (!body.domain_id || !body.address) return c.json({ error: "domain_id and address required" }, 400);
|
|
const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
|
|
.bind(body.domain_id)
|
|
.first<{ id: string; name: string }>();
|
|
if (!domain) return c.json({ error: "domain not found" }, 404);
|
|
let address = body.address.trim().toLowerCase();
|
|
if (body.catch_all) address = `*@${domain.name}`;
|
|
else if (!address.includes("@")) address = `${address}@${domain.name}`;
|
|
else if (!address.endsWith(`@${domain.name}`)) return c.json({ error: `address must be @${domain.name}` }, 400);
|
|
const exists = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind(address).first();
|
|
if (exists) return c.json({ error: "mailbox already exists" }, 409);
|
|
const id = await upsertMailbox(c.env, {
|
|
domainId: body.domain_id,
|
|
address,
|
|
isCatchAll: Boolean(body.catch_all),
|
|
retentionSeconds: body.retention_seconds ? Number(body.retention_seconds) : undefined,
|
|
});
|
|
return c.json({ ok: true, id, address }, 201);
|
|
});
|
|
|
|
app.patch("/mailboxes/:id", async (c) => {
|
|
const id = c.req.param("id");
|
|
const body = await readJson<{ status?: string; retention_seconds?: number | null }>(c.req.raw);
|
|
if (body.status && !["active", "disabled"].includes(body.status)) {
|
|
return c.json({ error: "invalid status" }, 400);
|
|
}
|
|
const mailbox = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE id = ?").bind(id).first();
|
|
if (!mailbox) return c.json({ error: "not found" }, 404);
|
|
if (body.status) {
|
|
await c.env.DB.prepare("UPDATE mailboxes SET status = ?, updated_at = ? WHERE id = ?")
|
|
.bind(body.status, nowIso(), id)
|
|
.run();
|
|
}
|
|
if (body.retention_seconds !== undefined) {
|
|
await c.env.DB.prepare("UPDATE mailboxes SET retention_seconds = ?, updated_at = ? WHERE id = ?")
|
|
.bind(body.retention_seconds ? Number(body.retention_seconds) : null, nowIso(), id)
|
|
.run();
|
|
}
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.delete("/mailboxes/:id", async (c) => {
|
|
const id = c.req.param("id");
|
|
await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
|
|
return c.json({ ok: true });
|
|
});
|
|
}
|
|
|
|
/** # 函数
|
|
* Email 查询 / 详情 / 附件 / 手动删除
|
|
*/
|
|
function installEmailRoutes(app: App) {
|
|
app.get("/emails", async (c) => {
|
|
const url = new URL(c.req.url);
|
|
const { limit, offset } = pageParams(url);
|
|
const mailboxId = url.searchParams.get("mailbox_id");
|
|
const q = url.searchParams.get("q");
|
|
const conds: string[] = [];
|
|
const binds: unknown[] = [];
|
|
if (mailboxId) {
|
|
conds.push("mailbox_id = ?");
|
|
binds.push(mailboxId);
|
|
}
|
|
if (q) {
|
|
conds.push("(subject LIKE ? OR from_address LIKE ?)");
|
|
binds.push(`%${q}%`, `%${q}%`);
|
|
}
|
|
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
|
|
const rows = await c.env.DB.prepare(
|
|
`SELECT e.id, e.mailbox_id, e.message_id, e.from_address, e.to_addresses, e.subject, e.size,
|
|
e.received_at, e.expires_at, m.address AS mailbox_address,
|
|
(SELECT COUNT(*) FROM email_attachments a WHERE a.email_id = e.id) AS attachment_count
|
|
FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id
|
|
${where} ORDER BY e.received_at DESC LIMIT ? OFFSET ?`,
|
|
);
|
|
const res = await (binds.length ? rows.bind(...binds, limit, offset) : rows.bind(limit, offset)).all();
|
|
const totalRows = binds.length
|
|
? await c.env.DB.prepare(`SELECT COUNT(*) AS n FROM emails ${where}`).bind(...binds).first<{ n: number }>()
|
|
: await c.env.DB.prepare("SELECT COUNT(*) AS n FROM emails").first<{ n: number }>();
|
|
return c.json({ items: res.results, total: totalRows?.n ?? 0 });
|
|
});
|
|
|
|
app.get("/emails/:id", async (c) => {
|
|
const id = c.req.param("id");
|
|
const email = await c.env.DB.prepare(
|
|
`SELECT e.*, m.address AS mailbox_address FROM emails e
|
|
LEFT JOIN mailboxes m ON m.id = e.mailbox_id WHERE e.id = ?`,
|
|
)
|
|
.bind(id)
|
|
.first();
|
|
if (!email) return c.json({ error: "not found" }, 404);
|
|
const atts = await c.env.DB.prepare(
|
|
"SELECT id, filename, content_type, size, content_id FROM email_attachments WHERE email_id = ?",
|
|
)
|
|
.bind(id)
|
|
.all();
|
|
return c.json({ email, attachments: atts.results });
|
|
});
|
|
|
|
/** 附件下载 */
|
|
app.get("/emails/:id/attachments/:attId", async (c) => {
|
|
const attId = c.req.param("attId");
|
|
const att = await c.env.DB.prepare("SELECT filename, content_type, storage_key FROM email_attachments WHERE id = ? AND email_id = ?")
|
|
.bind(attId, c.req.param("id"))
|
|
.first<{ filename: string; content_type: string; storage_key: string }>();
|
|
if (!att) return c.json({ error: "not found" }, 404);
|
|
const obj = await c.env.R2.get(att.storage_key);
|
|
if (!obj) return c.json({ error: "object missing" }, 404);
|
|
const buf = await obj.arrayBuffer();
|
|
return new Response(buf, {
|
|
headers: {
|
|
"Content-Type": att.content_type || "application/octet-stream",
|
|
"Content-Disposition": `attachment; filename="${encodeURIComponent(att.filename)}"`,
|
|
},
|
|
});
|
|
});
|
|
|
|
/** raw MIME 下载(.eml) */
|
|
app.get("/emails/:id/raw", async (c) => {
|
|
const email = await c.env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?")
|
|
.bind(c.req.param("id"))
|
|
.first<{ raw_storage_key: string }>();
|
|
if (!email) return c.json({ error: "not found" }, 404);
|
|
const obj = await c.env.R2.get(email.raw_storage_key);
|
|
if (!obj) return c.json({ error: "object missing" }, 404);
|
|
return new Response(await obj.arrayBuffer(), {
|
|
headers: {
|
|
"Content-Type": "message/rfc822",
|
|
"Content-Disposition": `attachment; filename="${c.req.param("id")}.eml"`,
|
|
},
|
|
});
|
|
});
|
|
|
|
app.delete("/emails/:id", async (c) => {
|
|
const id = c.req.param("id");
|
|
const email = await c.env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?")
|
|
.bind(id)
|
|
.first<{ raw_storage_key: string }>();
|
|
if (!email) return c.json({ error: "not found" }, 404);
|
|
const atts = await c.env.DB.prepare("SELECT storage_key FROM email_attachments WHERE email_id = ?")
|
|
.bind(id)
|
|
.all<{ storage_key: string | null }>();
|
|
const keys = [email.raw_storage_key, ...atts.results.map((a) => a.storage_key)].filter(Boolean) as string[];
|
|
if (keys.length) await c.env.R2.delete(keys);
|
|
await c.env.DB.prepare("DELETE FROM email_attachments WHERE email_id = ?").bind(id).run();
|
|
await c.env.DB.prepare("DELETE FROM webhook_deliveries WHERE email_id = ?").bind(id).run();
|
|
await c.env.DB.prepare("DELETE FROM emails WHERE id = ?").bind(id).run();
|
|
return c.json({ ok: true });
|
|
});
|
|
}
|
|
|
|
/** # 函数
|
|
* webhook endpoint 的基本格式校验(创建时调用)
|
|
*/
|
|
export function validateEndpoint(endpoint: string): string | null {
|
|
try {
|
|
new URL(endpoint);
|
|
} catch {
|
|
return "invalid url";
|
|
}
|
|
return checkWebhookUrl(endpoint);
|
|
}
|