- 账号化:节点更名账号,/nodes -> /accounts;添加账号直达 Cloudflare OAuth,自动创建账号并同步账号下全部域名(catch-all 邮箱随同步创建) - 域名:移除手工添加;点击域名『设置 Email Workers』= 部署账号级 Email Worker + Email Routing + catch-all;支持按账号筛选 - Webhook 三级模型:邮箱 -> 域名 -> 全局,收信取第一个命中者;支持自定义请求头与 key(默认 Authorization: Bearer <key>,兼做 X-WmailHub-Signature 签名);独立 webhooks 实体与 /webhooks、/keys 页面删除,Key 管理收进设置模态窗口 - 设置中心:全局 Webhook / 默认 Node Ingress 地址 / 修改密码 / API & MCP Keys(生成、撤销、删除) - UI:Header 标题副标题紧凑化(修复溢出),LOGO 加版本号 tag(v1.1.0 取自 package.json),各列表页搜索框 - 工具链:前端构建 Bun.build -> Vite(vite 8 + @vitejs/plugin-react) - 后端:/api/settings、POST /api/domains/:id/setup、0002 迁移(投递行固化三级目标配置);MCP 去 list_webhooks(5 工具);迁移执行器加 _migrations 记录 - 测试:44/44(新增 settings 套件、webhook 三级优先级/SSRF/签名);UI 冒烟 11/11
177 lines
7.0 KiB
TypeScript
177 lines
7.0 KiB
TypeScript
/**
|
|
* 本地开发一键启动:console + node + email worker。
|
|
* bun run dev # 常规启动(复用既有数据)
|
|
* bun run dev -- --reset # 清空本地数据后全新安装
|
|
*
|
|
* 流程:
|
|
* 1. 构建前端(vite build)
|
|
* 2. 启动 Console(:18787,自动迁移 + admin/admin 种子)
|
|
* 3. 确保存在本地账号(无则 OAuth sim 自动创建账号 + 同步域名),并逐域名配置 Email Workers
|
|
* 4. 启动 Node(:18788,enrollment 换取身份,config.json 0600 落盘)
|
|
* 5. 启动 Email Worker(:18789,转发到 Node)
|
|
* 6. Ctrl-C 一起退出
|
|
*/
|
|
|
|
const ROOT = new URL("..", import.meta.url).pathname;
|
|
const CONSOLE_PORT = 18787;
|
|
const NODE_PORT = 18788;
|
|
const WORKER_PORT = 18789;
|
|
const BASE = `http://127.0.0.1:${CONSOLE_PORT}`;
|
|
|
|
const reset = process.argv.includes("--reset");
|
|
if (reset) {
|
|
await Bun.$`rm -rf ${ROOT}console/data ${ROOT}node/config.json`.quiet();
|
|
console.log("[dev] 本地数据已重置");
|
|
}
|
|
|
|
/** 前端构建(产物已存在时可跳过:SKIP_BUILD=1) */
|
|
if (!process.env.SKIP_BUILD) {
|
|
console.log("[dev] 构建前端...");
|
|
await Bun.$`bun run build`.cwd(ROOT);
|
|
}
|
|
|
|
interface Child {
|
|
name: string;
|
|
proc: Bun.Subprocess<"ignore", "inherit", "inherit">;
|
|
}
|
|
|
|
const children: Child[] = [];
|
|
|
|
/** 启动子进程并登记 */
|
|
function spawn(name: string, args: string[], cwd: string, env?: Record<string, string>) {
|
|
const proc = Bun.spawn(["bun", ...args], {
|
|
cwd,
|
|
env: { ...process.env, ...env },
|
|
stdout: "inherit",
|
|
stderr: "inherit",
|
|
stdin: "ignore",
|
|
});
|
|
children.push({ name, proc });
|
|
return proc;
|
|
}
|
|
|
|
/** 等待 HTTP 就绪 */
|
|
async function waitReady(url: string, label: string, tries = 60): Promise<void> {
|
|
for (let i = 0; i < tries; i++) {
|
|
try {
|
|
const resp = await fetch(url);
|
|
if (resp.ok) return;
|
|
} catch {
|
|
// not ready
|
|
}
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
throw new Error(`${label} 未就绪:${url}`);
|
|
}
|
|
|
|
// 1. Console
|
|
spawn("console", ["console/src/local/main.ts"], ROOT);
|
|
await waitReady(`${BASE}/api/health`, "console");
|
|
console.log("[dev] console ready");
|
|
|
|
// 2. 登录拿会话
|
|
const loginResp = await fetch(`${BASE}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: "admin", password: "admin" }),
|
|
});
|
|
if (!loginResp.ok) throw new Error("admin 登录失败");
|
|
const cookie = (loginResp.headers.get("set-cookie") ?? "").split(";")[0];
|
|
const authHeaders = { Cookie: cookie, "Content-Type": "application/json" };
|
|
|
|
// 3. 确保本地账号存在(无则走 OAuth 自动创建);拿到 ingress secret 与 enrollment token
|
|
const accountsResp = await fetch(`${BASE}/api/accounts`, { headers: authHeaders });
|
|
const existing = ((await accountsResp.json()) as { items: { id: string; name: string }[] }).items;
|
|
let nodeId: string;
|
|
let ingressSecret: string;
|
|
let enrollmentToken: string | null = null;
|
|
|
|
if (existing.length > 0) {
|
|
nodeId = existing[0].id;
|
|
ingressSecret = ((await (await fetch(`${BASE}/api/accounts/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret;
|
|
// 无论 config.json 是否存在,都发一个新 token:有身份的 Node 会忽略它,无身份的用它 enrollment
|
|
const rotated = (await (
|
|
await fetch(`${BASE}/api/accounts/${nodeId}/rotate`, { method: "POST", headers: authHeaders })
|
|
).json()) as { enrollment_token: string };
|
|
enrollmentToken = rotated.enrollment_token;
|
|
console.log(`[dev] 复用既有账号 ${nodeId}`);
|
|
} else {
|
|
// 添加账号:OAuth start 不带 node_id,服务端自动创建账号
|
|
const startResp = await fetch(`${BASE}/api/oauth/cloudflare/start`, { headers: authHeaders });
|
|
const { state } = (await startResp.json()) as { state: string };
|
|
const approve = await fetch(`${BASE}/api/oauth/cloudflare/sim/approve`, {
|
|
method: "POST",
|
|
headers: authHeaders,
|
|
body: JSON.stringify({ state }),
|
|
});
|
|
const approveRes = (await approve.json()) as { ok: boolean; error?: string; node_id: string };
|
|
if (!approveRes.ok) throw new Error(`自动创建账号失败:${approveRes.error}`);
|
|
nodeId = approveRes.node_id;
|
|
ingressSecret = ((await (await fetch(`${BASE}/api/accounts/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret;
|
|
const rotated = (await (
|
|
await fetch(`${BASE}/api/accounts/${nodeId}/rotate`, { method: "POST", headers: authHeaders })
|
|
).json()) as { enrollment_token: string };
|
|
enrollmentToken = rotated.enrollment_token;
|
|
console.log(`[dev] OAuth(sim)已创建账号 ${nodeId} 并同步域名`);
|
|
}
|
|
|
|
// 4. 域名同步后逐域名配置 Email Workers(sim 模式记录;幂等)
|
|
const domainsResp = await fetch(`${BASE}/api/domains`, { headers: authHeaders });
|
|
const domains = ((await domainsResp.json()) as { items: { id: string; name: string; status: string }[] }).items;
|
|
for (const d of domains.filter((x) => x.status !== "active")) {
|
|
const setup = (await (
|
|
await fetch(`${BASE}/api/domains/${d.id}/setup`, { method: "POST", headers: authHeaders })
|
|
).json()) as { ok: boolean; error?: string };
|
|
if (!setup.ok) throw new Error(`域名 ${d.name} 配置 Email Workers 失败:${setup.error}`);
|
|
console.log(`[dev] 域名 ${d.name} Email Workers 已配置`);
|
|
}
|
|
|
|
// 5. Node(首次用 enrollment token 换身份;之后 config.json 已有身份)
|
|
const nodeEnv: Record<string, string> = {
|
|
WMAILHUB_CONSOLE_URL: BASE,
|
|
WMAILHUB_INGRESS_SECRET: ingressSecret,
|
|
WMAILHUB_PORT: String(NODE_PORT),
|
|
};
|
|
if (enrollmentToken) nodeEnv.WMAILHUB_ENROLLMENT_TOKEN = enrollmentToken;
|
|
spawn("node", ["node/src/main.ts"], ROOT, nodeEnv);
|
|
await waitReady(`http://127.0.0.1:${NODE_PORT}/__health`, "node");
|
|
console.log("[dev] node ready");
|
|
|
|
// 6. Email Worker
|
|
spawn("worker", ["worker/src/local.ts"], ROOT, {
|
|
NODE_INGRESS_URL: `http://127.0.0.1:${NODE_PORT}/ingress/email`,
|
|
NODE_INGRESS_SECRET: ingressSecret,
|
|
WMAILHUB_WORKER_PORT: String(WORKER_PORT),
|
|
});
|
|
await waitReady(`http://127.0.0.1:${WORKER_PORT}/__health`, "email worker");
|
|
console.log("[dev] email worker ready");
|
|
|
|
console.log("");
|
|
console.log("======== WmailHub 本地环境已就绪 ========");
|
|
console.log(` Console : ${BASE} (admin / admin)`);
|
|
console.log(` Node : http://127.0.0.1:${NODE_PORT}/__health`);
|
|
console.log(` Email Worker: http://127.0.0.1:${WORKER_PORT}/__health`);
|
|
console.log(" 发测试邮件 : curl -X POST http://127.0.0.1:18789/__email_test \\");
|
|
console.log(" -H 'X-WmailHub-Ingress-Secret: <secret>' \\");
|
|
console.log(" -H 'X-WmailHub-Test-To: hello@example.test' --data-binary @mail.eml");
|
|
console.log("==========================================");
|
|
|
|
/** 退出时统一收割子进程 */
|
|
function shutdown() {
|
|
for (const { name, proc } of children) {
|
|
proc.kill();
|
|
console.log(`[dev] ${name} stopped`);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|
|
|
|
// 任一子进程退出则整体退出
|
|
for (const { name, proc } of children) {
|
|
void proc.exited.then((code) => {
|
|
console.error(`[dev] ${name} exited with ${code}`);
|
|
shutdown();
|
|
});
|
|
}
|