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:
@@ -28,7 +28,7 @@ const MENU_ITEMS = [
|
||||
/** # 成员变量: 路由 -> 页面标题/副标题 **/
|
||||
const PAGE_META: Record<string, { title: string; subtitle: string }> = {
|
||||
"/": { title: "概览", subtitle: "账号、域名与邮件总览" },
|
||||
"/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号" },
|
||||
"/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号(Email Worker 直连)" },
|
||||
"/domains": { title: "域名", subtitle: "OAuth 自动同步的收件域名与 Email Workers" },
|
||||
"/mailboxes": { title: "邮箱", subtitle: "收件地址、保留策略与邮箱级 Webhook" },
|
||||
"/emails": { title: "邮件", subtitle: "已接收邮件(默认保留 1 天)" },
|
||||
@@ -85,7 +85,7 @@ export function App() {
|
||||
<div className="subtitle">{meta.subtitle}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<Tooltip title="设置(全局 Webhook / Ingress / 密码 / API & MCP Keys)">
|
||||
<Tooltip title="设置(全局 Webhook / 密码 / API & MCP Keys)">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
|
||||
+20
-20
@@ -88,8 +88,8 @@ export const health = () => api<{ ok: boolean; name: string; version: string }>(
|
||||
/** Dashboard 统计 */
|
||||
export const stats = () =>
|
||||
api<{
|
||||
accounts_online: number;
|
||||
accounts_total: number;
|
||||
accounts_active: number;
|
||||
domains: number;
|
||||
domains_active: number;
|
||||
mailboxes: number;
|
||||
@@ -98,59 +98,61 @@ export const stats = () =>
|
||||
global_webhook: number;
|
||||
deliveries_pending: number;
|
||||
oauth_connections: number;
|
||||
worker_credentials: number;
|
||||
recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[];
|
||||
}>("/api/admin/stats");
|
||||
|
||||
/** 手动触发 Cron */
|
||||
export const runCron = () => api<{ ok: boolean; stats: Record<string, number | string[]> }>("/api/admin/cron/run", "POST");
|
||||
|
||||
/* ---- 账号(原节点) ---- */
|
||||
/* ---- 账号(Cloudflare 账户) ---- */
|
||||
|
||||
export interface AccountRow {
|
||||
id: string;
|
||||
name: string;
|
||||
cloudflare_account_id: string | null;
|
||||
worker_id: string | null;
|
||||
status: string;
|
||||
version: string | null;
|
||||
last_seen_at: string | null;
|
||||
ingress_url: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const listAccounts = () => api<{ items: AccountRow[]; total: number }>("/api/accounts");
|
||||
|
||||
export interface AccountDetail {
|
||||
account: AccountRow & { enrollment_expires_at: string | null };
|
||||
account: AccountRow;
|
||||
steps: Record<string, { status: string; error?: string }>;
|
||||
oauth: { id: string; account_id: string | null; created_at: string } | null;
|
||||
domains: { id: string; name: string; status: string; worker_id: string | null }[];
|
||||
credential: { id: string; worker_id: string; secret_last4: string; status: string; created_at: string } | null;
|
||||
}
|
||||
|
||||
export const getAccount = (id: string) => api<AccountDetail>(`/api/accounts/${id}`);
|
||||
|
||||
export const updateAccount = (id: string, body: { name?: string; ingress_url?: string }) =>
|
||||
export const updateAccount = (id: string, body: { name?: string }) =>
|
||||
api(`/api/accounts/${id}`, "PATCH", body);
|
||||
|
||||
export const deleteAccount = (id: string) => api(`/api/accounts/${id}`, "DELETE");
|
||||
|
||||
/** 轮换 Worker credential:secret 仅本次返回可见;已配置域名自动重新部署 */
|
||||
export const rotateAccount = (id: string) =>
|
||||
api<{ ok: boolean; enrollment_token: string }>(`/api/accounts/${id}/rotate`, "POST");
|
||||
api<{ ok: boolean; worker_id: string; credential_id: string; secret: string; redeployed: string[] }>(
|
||||
`/api/accounts/${id}/rotate`,
|
||||
"POST",
|
||||
);
|
||||
|
||||
export const getAccountIngressSecret = (id: string) =>
|
||||
api<{ ingress_secret: string }>(`/api/accounts/${id}/ingress-secret`);
|
||||
/** 本地/运维:查看账号当前 Worker credential(secret 用于部署/联调) */
|
||||
export const getAccountWorkerSecret = (id: string) =>
|
||||
api<{ worker_id: string; secret: string }>(`/api/accounts/${id}/worker-secret`);
|
||||
|
||||
/* ---- OAuth ---- */
|
||||
|
||||
export const oauthMode = () => api<{ mode: "cloudflare" | "sim" }>("/api/oauth/mode");
|
||||
|
||||
/** 开始授权:nodeId 缺省 = 自动创建账号 */
|
||||
export const oauthStart = (nodeId?: string) =>
|
||||
api<{ url: string; state: string }>(
|
||||
`/api/oauth/cloudflare/start${nodeId ? `?node_id=${encodeURIComponent(nodeId)}` : ""}`,
|
||||
);
|
||||
/** 开始授权(自动创建账号记录;授权后自动接入) */
|
||||
export const oauthStart = () => api<{ url: string; state: string }>("/api/oauth/cloudflare/start");
|
||||
|
||||
export const oauthSimApprove = (state: string) =>
|
||||
api<{ ok: boolean; error?: string; node_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state });
|
||||
api<{ ok: boolean; error?: string; account_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state });
|
||||
|
||||
/* ---- 域名 ---- */
|
||||
|
||||
@@ -161,8 +163,8 @@ export interface DomainRow {
|
||||
setup_error: string | null;
|
||||
worker_id: string | null;
|
||||
zone_id: string | null;
|
||||
node_id: string | null;
|
||||
node_name: string | null;
|
||||
account_id: string | null;
|
||||
account_name: string | null;
|
||||
webhook_url: string | null;
|
||||
webhook_headers: string | null;
|
||||
webhook_has_key: number;
|
||||
@@ -257,7 +259,6 @@ export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
|
||||
|
||||
export interface SettingsInfo {
|
||||
username: string;
|
||||
default_ingress_url: string;
|
||||
webhook: { url: string; headers: Record<string, string>; has_key: boolean; key_last4: string };
|
||||
}
|
||||
|
||||
@@ -265,7 +266,6 @@ export const getSettings = () => api<SettingsInfo>("/api/settings");
|
||||
|
||||
/** 保存设置;webhook.key:undefined 保留原值、空串清除、非空设置新 key */
|
||||
export const putSettings = (body: {
|
||||
default_ingress_url?: string;
|
||||
webhook?: { url: string; headers?: Record<string, string>; key?: string };
|
||||
}) => api("/api/settings", "PUT", body);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 设置模态窗口(v1.1.0):
|
||||
* - 基本设置:全局 Webhook(url/自定义头/key)+ 默认 Node Ingress 地址 + 修改密码
|
||||
* 设置模态窗口(v2.0.0):
|
||||
* - 基本设置:全局 Webhook(url/自定义头/key)+ 修改密码
|
||||
* - API Keys / MCP Keys:wmh_ 凭据的生成、撤销、删除(原 /keys 页面收拢于此)
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -71,12 +71,11 @@ interface WebhookFormValues {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 基本设置:全局 Webhook + 默认 Node Ingress + 修改密码
|
||||
* 基本设置:全局 Webhook + 修改密码
|
||||
*/
|
||||
function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [webhookForm] = Form.useForm<WebhookFormValues>();
|
||||
const [ingressForm] = Form.useForm<{ ingress: string }>();
|
||||
const [pwdForm] = Form.useForm<{
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
@@ -91,7 +90,7 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
||||
});
|
||||
}, [info, webhookForm]);
|
||||
|
||||
/** 保存全局 Webhook 与默认 Ingress */
|
||||
/** 保存全局 Webhook */
|
||||
const saveWebhook = async () => {
|
||||
const values = await webhookForm.validateFields();
|
||||
let headers: Record<string, string> = {};
|
||||
@@ -106,7 +105,6 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
||||
setSaving(true);
|
||||
try {
|
||||
await putSettings({
|
||||
default_ingress_url: info.default_ingress_url,
|
||||
webhook: { url: values.url.trim(), headers, key: values.key },
|
||||
});
|
||||
message.success("全局 Webhook 已保存");
|
||||
@@ -119,18 +117,6 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
||||
}
|
||||
};
|
||||
|
||||
/** 保存默认 Node Ingress 地址 */
|
||||
const saveIngress = async () => {
|
||||
const values = await ingressForm.validateFields();
|
||||
try {
|
||||
await putSettings({ default_ingress_url: values.ingress.trim() });
|
||||
message.success("默认 Node Ingress 地址已保存");
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
/** 修改密码 */
|
||||
const savePassword = async () => {
|
||||
const values = await pwdForm.validateFields();
|
||||
@@ -179,20 +165,6 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Form form={ingressForm} layout="vertical" initialValues={{ ingress: info.default_ingress_url }}>
|
||||
<Typography.Title level={5}>默认 Node Ingress 地址</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ display: "block", marginBottom: 8 }}>
|
||||
「添加账号」创建的账号默认使用的 Node Ingress 地址:即 Email Worker 收到邮件后转发到的 Node 接收入口
|
||||
(Node 再签名转发给 Console)。生产环境填 Node 的公网 HTTPS 地址。
|
||||
</Typography.Text>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Form.Item name="ingress" noStyle rules={[{ required: true, message: "请输入地址" }]}>
|
||||
<Input placeholder="http://127.0.0.1:18788/ingress/email" />
|
||||
</Form.Item>
|
||||
<Button onClick={() => void saveIngress()}>保存</Button>
|
||||
</Space.Compact>
|
||||
</Form>
|
||||
|
||||
<Form form={pwdForm} layout="vertical" onFinish={() => void savePassword()}>
|
||||
<Typography.Title level={5}>修改密码</Typography.Title>
|
||||
<Form.Item name="old_password" label="原密码" rules={[{ required: true, message: "请输入原密码" }]}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 账号页(原节点):添加账号直达 Cloudflare OAuth,授权后自动同步域名;搜索、详情、凭据管理 */
|
||||
/** 账号页:添加账号直达 Cloudflare OAuth,授权后自动接入并同步域名;搜索、详情、Worker 凭据管理 */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
@@ -17,7 +17,6 @@ import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"
|
||||
import {
|
||||
deleteAccount,
|
||||
getAccount,
|
||||
getAccountIngressSecret,
|
||||
listAccounts,
|
||||
oauthMode,
|
||||
oauthStart,
|
||||
@@ -31,17 +30,16 @@ import {
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending_oauth: "orange",
|
||||
provisioning: "processing",
|
||||
online: "success",
|
||||
offline: "default",
|
||||
active: "success",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
/** # 成员变量: 安装步骤中文标签(与后端 PROVISION_STEPS 对应) **/
|
||||
/** # 成员变量: 接入步骤中文标签(与后端 PROVISION_STEPS 对应) **/
|
||||
const STEP_LABEL: Record<string, string> = {
|
||||
oauth: "Cloudflare OAuth 授权",
|
||||
identity: "Node identity 与 credential",
|
||||
discover: "Account / Zone 发现",
|
||||
domains: "域名与 catch-all 邮箱同步",
|
||||
worker: "Email Worker 部署与 Worker 凭据",
|
||||
};
|
||||
|
||||
/** # 函数
|
||||
@@ -92,12 +90,11 @@ export function AccountsPage() {
|
||||
[items, q],
|
||||
);
|
||||
|
||||
/** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页。
|
||||
* accountId 缺省 = 服务端自动创建账号。 */
|
||||
const startOAuth = async (accountId?: string) => {
|
||||
/** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页 */
|
||||
const startOAuth = async () => {
|
||||
try {
|
||||
const mode = (await oauthMode()).mode;
|
||||
const { url } = await oauthStart(accountId);
|
||||
const { url } = await oauthStart();
|
||||
if (mode === "cloudflare") window.location.href = url;
|
||||
else navigate(url); // /oauth/sim/authorize?state=...
|
||||
} catch (err) {
|
||||
@@ -122,7 +119,8 @@ export function AccountsPage() {
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<span className="muted">
|
||||
点击「添加账号」完成 Cloudflare OAuth,自动同步账号下全部域名;在域名列表逐域名开启 Email Workers。
|
||||
点击「添加账号」完成 Cloudflare OAuth;Console 自动同步域名、部署 Email Worker
|
||||
并签发 Worker 凭据(Email Worker 直连 Console)。
|
||||
</span>
|
||||
</div>
|
||||
<Space>
|
||||
@@ -149,29 +147,23 @@ export function AccountsPage() {
|
||||
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
|
||||
},
|
||||
{ title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
{ title: "Ingress", dataIndex: "ingress_url", ellipsis: true, render: (v: string) => <span className="mono">{v}</span> },
|
||||
{ title: "版本", dataIndex: "version", width: 90, render: (v: string | null) => v ?? "-" },
|
||||
{
|
||||
title: "最近心跳",
|
||||
dataIndex: "last_seen_at",
|
||||
title: "Email Worker",
|
||||
dataIndex: "worker_id",
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => (v ? <span className="mono">{v}</span> : <span className="muted">未部署</span>),
|
||||
},
|
||||
{
|
||||
title: "接入时间",
|
||||
dataIndex: "created_at",
|
||||
width: 170,
|
||||
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
|
||||
render: (v: string) => new Date(v).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 150,
|
||||
width: 100,
|
||||
render: (_: unknown, row: AccountRow) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={row.status !== "pending_oauth"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void startOAuth(row.id);
|
||||
}}
|
||||
>
|
||||
授权
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
@@ -179,7 +171,7 @@ export function AccountsPage() {
|
||||
e.stopPropagation();
|
||||
modal.confirm({
|
||||
title: `删除账号 ${row.name}?`,
|
||||
content: "将撤销账号凭据并标记为 revoked;Node 内部协议将立即失效。",
|
||||
content: "将撤销该账号的 Worker 凭据并标记为 revoked;Email Worker 将无法访问 Console。",
|
||||
onOk: async () => {
|
||||
await deleteAccount(row.id);
|
||||
message.success("已删除");
|
||||
@@ -211,7 +203,7 @@ export function AccountsPage() {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 账号详情抽屉:安装步骤时间线 + 域名 + ingress/凭据管理
|
||||
* 账号详情抽屉:接入步骤时间线 + 域名 + Email Worker 凭据管理
|
||||
*/
|
||||
function AccountDetailDrawer({
|
||||
detail,
|
||||
@@ -225,14 +217,10 @@ function AccountDetailDrawer({
|
||||
onReload: () => void;
|
||||
}) {
|
||||
const { message, modal } = App.useApp();
|
||||
const [ingress, setIngress] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (detail) {
|
||||
setIngress(detail.account.ingress_url);
|
||||
setName(detail.account.name);
|
||||
}
|
||||
if (detail) setName(detail.account.name);
|
||||
}, [detail]);
|
||||
|
||||
if (!detail) return null;
|
||||
@@ -242,7 +230,7 @@ function AccountDetailDrawer({
|
||||
<Drawer title={`账号:${detail.account.name}`} size="large" open={open} onClose={onClose}>
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<Typography.Title level={5}>安装进度</Typography.Title>
|
||||
<Typography.Title level={5}>接入进度</Typography.Title>
|
||||
<Timeline
|
||||
items={Object.entries(STEP_LABEL).map(([key, label]) => {
|
||||
const st = steps[key]?.status ?? "pending";
|
||||
@@ -278,58 +266,54 @@ function AccountDetailDrawer({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="Ingress 与凭据">
|
||||
<Card size="small" title="Email Worker 与凭据">
|
||||
<Space orientation="vertical" style={{ width: "100%" }} size="middle">
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
placeholder="账号名称"
|
||||
/>
|
||||
<Input
|
||||
value={ingress}
|
||||
onChange={(e) => setIngress(e.target.value)}
|
||||
placeholder="Node Ingress 地址"
|
||||
/>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await updateAccount(detail.account.id, { name, ingress_url: ingress });
|
||||
message.success("已保存(新邮件将从新 Ingress 转发)");
|
||||
await updateAccount(detail.account.id, { name });
|
||||
message.success("已保存");
|
||||
onReload();
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Typography.Text type="secondary" className="mono">
|
||||
Worker ID:{detail.account.worker_id ?? "-"}
|
||||
{detail.credential ? ` · 凭据 ****${detail.credential.secret_last4}(有效)` : ""}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ display: "block" }}>
|
||||
Email Worker 以该凭据直连 Console(/internal/email/authorize 与 /ingest);轮换后已配置的域名会自动重新部署。
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const res = await rotateAccount(detail.account.id);
|
||||
modal.info({
|
||||
title: "新的 Enrollment Token(仅显示一次)",
|
||||
content: <Typography.Paragraph copyable className="mono">{res.enrollment_token}</Typography.Paragraph>,
|
||||
title: "新的 Worker Secret(仅显示一次)",
|
||||
content: (
|
||||
<div>
|
||||
<Typography.Paragraph copyable className="mono" style={{ marginBottom: 4 }}>
|
||||
{res.secret}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text type="secondary">
|
||||
已自动重新部署域名:{res.redeployed.length ? res.redeployed.join("、") : "无(尚无已配置域名)"}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
onReload();
|
||||
}}
|
||||
>
|
||||
轮换凭据(生成新 Enrollment Token)
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const res = await getAccountIngressSecret(detail.account.id);
|
||||
modal.info({
|
||||
title: "Ingress Secret(Email Worker -> Node 共享密钥)",
|
||||
content: <Typography.Paragraph copyable className="mono">{res.ingress_secret}</Typography.Paragraph>,
|
||||
});
|
||||
}}
|
||||
>
|
||||
查看 Ingress Secret
|
||||
轮换 Worker 凭据
|
||||
</Button>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" className="mono">
|
||||
enrollment 有效期至 {detail.account.enrollment_expires_at ?? "-"}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function DashboardPage() {
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="账号在线" value={data?.accounts_online ?? 0} suffix={`/ ${data?.accounts_total ?? 0}`} />
|
||||
<Statistic title="Cloudflare 账号" value={data?.accounts_active ?? 0} suffix={`/ ${data?.accounts_total ?? 0}`} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
|
||||
@@ -58,7 +58,7 @@ export function DomainsPage() {
|
||||
() =>
|
||||
items.filter(
|
||||
(row) =>
|
||||
(!accountId || row.node_id === accountId) &&
|
||||
(!accountId || row.account_id === accountId) &&
|
||||
row.name.toLowerCase().includes(q.trim().toLowerCase()),
|
||||
),
|
||||
[items, q, accountId],
|
||||
@@ -110,7 +110,7 @@ export function DomainsPage() {
|
||||
onRow={(row) => ({ onClick: () => setEditing(row), style: { cursor: "pointer" } })}
|
||||
columns={[
|
||||
{ title: "域名", dataIndex: "name", render: (v: string) => <span className="mono">{v}</span> },
|
||||
{ title: "账号", dataIndex: "node_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
{ title: "账号", dataIndex: "account_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
{ title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => <StatusTag row={row} /> },
|
||||
{
|
||||
title: "Email Worker",
|
||||
@@ -174,7 +174,7 @@ export function DomainsPage() {
|
||||
<span>
|
||||
<span className="mono">{editing.name}</span>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8, fontWeight: 400 }}>
|
||||
{editing.node_name ?? "-"}
|
||||
{editing.account_name ?? "-"}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -20,7 +20,7 @@ export function OAuthSimPage() {
|
||||
const res = await oauthSimApprove(state);
|
||||
if (res.ok) {
|
||||
message.success("Cloudflare 授权与自动安装完成");
|
||||
navigate(`/accounts?provision=${encodeURIComponent(res.node_id)}`, { replace: true });
|
||||
navigate(`/accounts?provision=${encodeURIComponent(res.account_id)}`, { replace: true });
|
||||
} else {
|
||||
message.error(res.error ?? "安装失败");
|
||||
navigate("/accounts", { replace: true });
|
||||
|
||||
+3
-3
@@ -5,11 +5,11 @@
|
||||
* /api/health 健康检查
|
||||
* /api/auth/* 用户登录/登出/会话(cookie)
|
||||
* /api/accounts|domains|mailboxes|emails 管理资源 CRUD(会话保护)
|
||||
* /api/settings/* 设置:全局 Webhook / 默认 Ingress / 改密码(会话保护)
|
||||
* /api/settings/* 设置:全局 Webhook / 改密码(会话保护)
|
||||
* /api/keys/* API/MCP Key 管理(会话保护;设置模态窗口内使用)
|
||||
* /api/oauth/* Cloudflare OAuth(会话保护)
|
||||
* /api/admin/* 版本/统计/Cron(会话保护)
|
||||
* /api/internal/* Node 内部协议(HMAC 签名)
|
||||
* /internal/email/* Email Worker 内部协议:authorize / ingest(HMAC 签名,docs/11)
|
||||
* /api/v1/mcp MCP JSON-RPC(Bearer MCP Key wmh_)
|
||||
* /api/v1/* 外部 API(Bearer API Key wmh_)
|
||||
* /* 前端 SPA 静态资源
|
||||
@@ -52,7 +52,7 @@ export function createApp(
|
||||
app.get("/api/health", (c) => c.json({ ok: true, name: APP_NAME, version: APP_VERSION }));
|
||||
|
||||
app.route("/api/auth", authRoutes());
|
||||
app.route("/api/internal", internalRoutes());
|
||||
app.route("/internal", internalRoutes());
|
||||
app.route("/api/oauth", oauthRoutes());
|
||||
app.route("/api/settings", settingsRoutes());
|
||||
app.route("/api/keys", keyRoutes());
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ export async function getMasterSecret(env: Env): Promise<string> {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 获取 AES 加密密钥(OAuth token、node credential secret、ingress secret 加密存储)
|
||||
* 获取 AES 加密密钥(OAuth token、worker credential secret、webhook key 加密存储)
|
||||
*/
|
||||
export async function getAesKey(env: Env): Promise<CryptoKey> {
|
||||
const master = await getMasterSecret(env);
|
||||
|
||||
+5
-3
@@ -78,8 +78,10 @@ export interface AppVars {
|
||||
/** 已认证 API/MCP Key 信息 **/
|
||||
apiKeyKind?: "api" | "mcp";
|
||||
apiKeyId?: string;
|
||||
/** internal 路由中已读出的原始请求体(nodeAuth 中间件缓存) **/
|
||||
/** internal 路由中已读出的原始请求体(workerAuth 中间件缓存) **/
|
||||
rawBody?: Uint8Array;
|
||||
/** internal 路由中通过校验的 credential id **/
|
||||
nodeCredentialId?: string;
|
||||
/** internal 路由中通过校验的 worker credential id **/
|
||||
workerCredentialId?: string;
|
||||
/** internal 路由中 credential 所属的 Console 账号 id **/
|
||||
workerAccountId?: string;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* - RealCFApi:真实调用 api.cloudflare.com / dash.cloudflare.com(OAuth access token)
|
||||
* - SimCFApi :本地 sim 模式。不访问外网,把"创建"的资源记录到 cf_sim_resources 表,
|
||||
* 让 Add Node 自动安装链路在本地完整可演示、可测试。
|
||||
* 让 Connect Cloudflare Account 自动接入链路在本地完整可演示、可测试。
|
||||
*
|
||||
* Console 通过 env.CF_OAUTH_CLIENT_ID/SECRET 判断模式;两者齐备为 real,否则 sim。
|
||||
*/
|
||||
@@ -51,13 +51,17 @@ export interface CFApi {
|
||||
listAccounts(accessToken: string): Promise<CFAccount[]>;
|
||||
/** 发现账户下的域名 Zone */
|
||||
listZones(accessToken: string, accountId: string): Promise<CFZone[]>;
|
||||
/** 创建或更新 Email Worker(幂等) */
|
||||
/** 创建或更新账号级 Email Worker(幂等) */
|
||||
putEmailWorker(opts: {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
ingressUrl: string;
|
||||
ingressSecret: string;
|
||||
/** Console 对外地址(Worker -> Console) **/
|
||||
consoleUrl: string;
|
||||
/** Worker 身份(与 WorkerCredential.worker_id 一致) **/
|
||||
workerId: string;
|
||||
/** Worker credential secret(HMAC 签名密钥) **/
|
||||
workerSecret: string;
|
||||
}): Promise<{ id: string }>;
|
||||
/** 开启 Zone 的 Email Routing(幂等) */
|
||||
enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean }>;
|
||||
@@ -136,12 +140,13 @@ export class RealCFApi implements CFApi {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
ingressUrl: string;
|
||||
ingressSecret: string;
|
||||
consoleUrl: string;
|
||||
workerId: string;
|
||||
workerSecret: string;
|
||||
}) {
|
||||
// Email Worker 即带 email handler 的 Worker 脚本;ingress 地址与密钥以明文常量打进脚本,
|
||||
// 避免给 Worker 配置 Console 凭据(docs/08:Worker 只持有 Node ingress secret)。
|
||||
const script = renderEmailWorkerScript(opts.ingressUrl, opts.ingressSecret);
|
||||
// Email Worker 即带 email handler 的 Worker 脚本;Console 地址与 Worker 凭据以明文常量打进脚本,
|
||||
// 避免给 Worker 配置额外的 secrets 绑定(docs/11 §8:凭据本身经 HMAC 保护 Console API)。
|
||||
const script = renderEmailWorkerScript(opts.consoleUrl, opts.workerId, opts.workerSecret);
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"metadata",
|
||||
@@ -223,14 +228,16 @@ export class SimCFApi implements CFApi {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
ingressUrl: string;
|
||||
ingressSecret: string;
|
||||
consoleUrl: string;
|
||||
workerId: string;
|
||||
workerSecret: string;
|
||||
}) {
|
||||
// 与真实实现同一模板渲染,但只记录不外发
|
||||
await this.record("email_worker", opts.scriptName, {
|
||||
account_id: opts.accountId,
|
||||
ingress_url: opts.ingressUrl,
|
||||
script: renderEmailWorkerScript(opts.ingressUrl, opts.ingressSecret).length,
|
||||
console_url: opts.consoleUrl,
|
||||
worker_id: opts.workerId,
|
||||
script: renderEmailWorkerScript(opts.consoleUrl, opts.workerId, opts.workerSecret).length,
|
||||
});
|
||||
return { id: opts.scriptName };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 邮件接收管线(docs/06):
|
||||
* Console 收到 Node 转发的 raw MIME 后:
|
||||
* 邮件接收管线(docs/06、docs/11):
|
||||
* Console 收到 Email Worker 提交的 raw MIME 后:
|
||||
* 校验大小限制 -> postal-mime 解析 -> 关联 Mailbox(精确匹配,否则 Domain catch-all)->
|
||||
* raw 写 R2、附件写 R2 -> emails/email_attachments 入库 -> 计算 expires_at ->
|
||||
* 为启用的 Webhook 创建 delivery(投递失败不影响邮件入库)。
|
||||
@@ -45,8 +45,8 @@ export interface IngestInput {
|
||||
mailFrom: string;
|
||||
/** SMTP 信封收件人(Email Worker 传递) **/
|
||||
rcptTo: string;
|
||||
/** 接收该邮件的 Node ID **/
|
||||
nodeId: string;
|
||||
/** 归属 Console 账号 id(Worker credential 所属账号) **/
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
/** # 成员变量: 入库结果 **/
|
||||
@@ -161,13 +161,13 @@ export async function ingestEmail(env: Env, input: IngestInput): Promise<IngestR
|
||||
|
||||
// 入库
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO emails (id, node_id, mailbox_id, message_id, from_address, to_addresses, cc_addresses,
|
||||
`INSERT INTO emails (id, account_id, mailbox_id, message_id, from_address, to_addresses, cc_addresses,
|
||||
subject, text_body, html_body, raw_storage_key, size, received_at, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
emailId,
|
||||
input.nodeId,
|
||||
input.accountId,
|
||||
mailboxId,
|
||||
parsed?.messageId ?? null,
|
||||
fromAddress,
|
||||
@@ -215,7 +215,7 @@ export class IngestError extends Error {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 收件地址 -> mailbox 解析:精确匹配优先,其次域名 catch-all
|
||||
* 收件地址 -> mailbox 解析:精确匹配优先,其次域名 catch-all(仅 active)
|
||||
*/
|
||||
export async function resolveMailbox(env: Env, addresses: string[]): Promise<string | null> {
|
||||
for (const addr of addresses) {
|
||||
|
||||
+175
-151
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* 账号(Node)自动安装管线(v1.1.0):
|
||||
* 添加账号(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 ->
|
||||
* Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱。
|
||||
* 账号接入管线(docs/11,Connect Cloudflare Account):
|
||||
* 添加账号(建 cloudflare_accounts 记录)-> Cloudflare OAuth -> 授权回调 ->
|
||||
* Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱 ->
|
||||
* Email Worker 部署 + WorkerCredential 签发(账号级)。
|
||||
*
|
||||
* Email Workers(Worker 部署 + Email Routing + catch-all 规则)不随 OAuth 自动执行,
|
||||
* 而是在域名列表中按域名单独触发(setupDomainEmail),一个账号只部署一个 Email Worker。
|
||||
*
|
||||
* 所有步骤幂等,重复执行不产生重复资源;账号级步骤状态写入 nodes.provision_steps。
|
||||
* Email Routing/catch-all 规则不随 OAuth 自动执行,而是在域名列表中按域名单独触发
|
||||
* (setupDomainEmail);一个账号只部署一个 Email Worker(幂等,重复执行不产生重复资源)。
|
||||
* 账号级步骤状态写入 cloudflare_accounts.provision_steps。
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
@@ -15,12 +15,12 @@ import { encryptSecret, decryptSecret, decryptConnectionToken } from "../db.ts";
|
||||
import { ids, sha256Hex, DEFAULT_RETENTION_SECONDS } from "../../../shared/src/index.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: 账号安装步骤定义(顺序即展示顺序) **/
|
||||
/** # 成员变量: 账号接入步骤定义(顺序即展示顺序) **/
|
||||
export const PROVISION_STEPS = [
|
||||
{ key: "oauth", label: "Cloudflare OAuth 授权" },
|
||||
{ key: "identity", label: "Node identity 与 credential" },
|
||||
{ key: "discover", label: "Account / Zone 发现" },
|
||||
{ key: "domains", label: "域名与 catch-all 邮箱同步" },
|
||||
{ key: "worker", label: "Email Worker 部署与 Worker 凭据" },
|
||||
] as const;
|
||||
|
||||
/** # 成员变量: 步骤状态 **/
|
||||
@@ -36,164 +36,131 @@ export function initialSteps(): Record<string, { status: StepStatus; error?: str
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 账号的 Email Worker 脚本名(幂等:同一账号固定同名)
|
||||
* 账号的 Email Worker 脚本名(幂等:同一账号固定同名;同时作为 Worker -> Console 的身份 ID)
|
||||
*/
|
||||
export function emailWorkerNameFor(nodeId: string): string {
|
||||
return `wmailhub-email-${nodeId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
|
||||
export function emailWorkerNameFor(accountId: string): string {
|
||||
return `wmailhub-email-${accountId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 添加账号:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。
|
||||
* 返回 enrollment token 供 Node bootstrap(本地由 dev 脚本自动完成,生产在 UI 展示一次)。
|
||||
* 添加账号:创建 cloudflare_accounts 记录(等待 OAuth 授权)。
|
||||
*/
|
||||
export async function createNode(
|
||||
export async function createAccount(
|
||||
env: Env,
|
||||
opts: { name: string; ingressUrl: string },
|
||||
): Promise<{
|
||||
nodeId: string;
|
||||
enrollmentToken: string;
|
||||
ingressSecret: string;
|
||||
}> {
|
||||
const nodeId = ids.node();
|
||||
opts: { name: string },
|
||||
): Promise<{ accountId: string }> {
|
||||
const accountId = ids.account();
|
||||
const now = nowIso();
|
||||
const ingressSecret = "whing_" + crypto.randomUUID().replace(/-/g, "");
|
||||
const enrollmentToken = "wenr_" + crypto.randomUUID().replace(/-/g, "");
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO nodes (id, name, cloudflare_account_id, credential_id, status, version,
|
||||
last_seen_at, ingress_url, ingress_secret_enc, enrollment_token, enrollment_expires_at,
|
||||
`INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status,
|
||||
provision_steps, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, NULL, 'pending_oauth', NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, NULL, NULL, 'pending_oauth', ?, ?, ?)`,
|
||||
)
|
||||
.bind(accountId, opts.name, JSON.stringify(initialSteps()), now, now)
|
||||
.run();
|
||||
return { accountId };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 确保账号存在可用的 Worker credential:
|
||||
* 已有 active credential 直接复用(不泄露 secret);否则签发全新 credential。
|
||||
* 返回 secret 明文仅供部署注入(本地测试/重新部署 Worker 时使用)。
|
||||
*/
|
||||
export async function ensureWorkerCredential(
|
||||
env: Env,
|
||||
accountId: string,
|
||||
workerId: string,
|
||||
): Promise<{ credentialId: string; secret: string | null }> {
|
||||
const now = nowIso();
|
||||
const existing = await env.DB.prepare(
|
||||
"SELECT id FROM worker_credentials WHERE cloudflare_account_id = ? AND worker_id = ? AND status = 'active'",
|
||||
)
|
||||
.bind(accountId, workerId)
|
||||
.first<{ id: string }>();
|
||||
if (existing) return { credentialId: existing.id, secret: null };
|
||||
|
||||
const secret = "whs_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
||||
const credentialId = ids.credential();
|
||||
await 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(
|
||||
nodeId,
|
||||
opts.name,
|
||||
opts.ingressUrl,
|
||||
await encryptSecret(env, ingressSecret),
|
||||
enrollmentToken,
|
||||
new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
||||
JSON.stringify(initialSteps()),
|
||||
now,
|
||||
credentialId,
|
||||
accountId,
|
||||
workerId,
|
||||
await sha256Hex(secret),
|
||||
await encryptSecret(env, secret),
|
||||
secret.slice(-4),
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
return { nodeId, enrollmentToken, ingressSecret };
|
||||
return { credentialId, secret };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Node bootstrap enrollment:用一次性 token 换取正式身份。
|
||||
* 每次调用都会轮换出全新 credential(旧的撤销),返回的 secret 仅此一次可见。
|
||||
*/
|
||||
export async function enrollNode(
|
||||
env: Env,
|
||||
enrollmentToken: string,
|
||||
): Promise<{
|
||||
node_id: string;
|
||||
credential_id: string;
|
||||
credential_secret: string;
|
||||
ingress_secret: string;
|
||||
console_url: string | null;
|
||||
} | null> {
|
||||
const node = await env.DB.prepare(
|
||||
"SELECT id, ingress_secret_enc, enrollment_expires_at FROM nodes WHERE enrollment_token = ? AND status != 'revoked'",
|
||||
)
|
||||
.bind(enrollmentToken)
|
||||
.first<{ id: string; ingress_secret_enc: string; enrollment_expires_at: string }>();
|
||||
if (!node) return null;
|
||||
if (node.enrollment_expires_at && node.enrollment_expires_at < nowIso()) return null;
|
||||
|
||||
// 轮换:撤销旧 credential,签发新 credential
|
||||
await env.DB.prepare(
|
||||
"UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'",
|
||||
)
|
||||
.bind(nowIso(), node.id)
|
||||
.run();
|
||||
const credentialId = ids.credential();
|
||||
const secret = "wns_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES (?, ?, ?, ?, 'active', ?)",
|
||||
)
|
||||
.bind(credentialId, node.id, await sha256Hex(secret), await encryptSecret(env, secret), nowIso())
|
||||
.run();
|
||||
await env.DB.prepare(
|
||||
"UPDATE nodes SET credential_id = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(credentialId, nowIso(), node.id)
|
||||
.run();
|
||||
|
||||
return {
|
||||
node_id: node.id,
|
||||
credential_id: credentialId,
|
||||
credential_secret: secret,
|
||||
ingress_secret: await decryptSecret(env, node.ingress_secret_enc),
|
||||
console_url: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** # 成员变量: 授权完成后的完整安装流程参数 **/
|
||||
/** # 成员变量: 授权完成后的完整接入流程参数 **/
|
||||
export interface ProvisionInput {
|
||||
nodeId: string;
|
||||
accountId: string;
|
||||
accessToken: string;
|
||||
accountId?: string;
|
||||
cfAccountId?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* OAuth 授权成功后的自动安装主流程:发现账号 + 同步全部域名(pending)+ catch-all 邮箱。
|
||||
* 幂等,可重复调用;返回是否全部成功;失败时步骤状态里带 error。
|
||||
* OAuth 授权成功后的自动接入主流程:发现账号 + 同步全部域名(pending)+ catch-all 邮箱 +
|
||||
* 部署账号级 Email Worker + 签发 Worker credential。幂等,可重复调用;返回是否全部成功。
|
||||
*/
|
||||
export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
|
||||
export async function provisionAccount(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
|
||||
const cf: CFApi = getCFApi(env, env.DB);
|
||||
const node = await env.DB.prepare("SELECT id, name FROM nodes WHERE id = ?")
|
||||
.bind(input.nodeId)
|
||||
.first<{ id: string; name: string }>();
|
||||
if (!node) return { ok: false, error: "account not found" };
|
||||
const account = await env.DB.prepare("SELECT id, name, status FROM cloudflare_accounts WHERE id = ?")
|
||||
.bind(input.accountId)
|
||||
.first<{ id: string; name: string; status: string }>();
|
||||
if (!account || account.status === "revoked") return { ok: false, error: "account not found" };
|
||||
|
||||
const steps = await loadSteps(env, input.nodeId);
|
||||
const steps = await loadSteps(env, input.accountId);
|
||||
const mark = async (key: string, status: StepStatus, error?: string) => {
|
||||
steps[key] = { status, error };
|
||||
await env.DB.prepare("UPDATE nodes SET provision_steps = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(JSON.stringify(steps), nowIso(), input.nodeId)
|
||||
await env.DB.prepare("UPDATE cloudflare_accounts SET provision_steps = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(JSON.stringify(steps), nowIso(), input.accountId)
|
||||
.run();
|
||||
};
|
||||
|
||||
try {
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'provisioning', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'provisioning', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.accountId)
|
||||
.run();
|
||||
|
||||
// 1. OAuth 授权
|
||||
await mark("oauth", "done");
|
||||
|
||||
// 2. Node identity/credential(在添加账号与 enrollment 时完成)
|
||||
await mark("identity", "done");
|
||||
|
||||
// 3. Account/Zone 发现
|
||||
// 2. Account/Zone 发现
|
||||
await mark("discover", "running");
|
||||
const accounts = await cf.listAccounts(input.accessToken);
|
||||
if (accounts.length === 0) throw new Error("no cloudflare account accessible");
|
||||
const account = input.accountId
|
||||
? accounts.find((a) => a.id === input.accountId) ?? accounts[0]
|
||||
const cfAccount = input.cfAccountId
|
||||
? accounts.find((a) => a.id === input.cfAccountId) ?? accounts[0]
|
||||
: accounts[0];
|
||||
// 自动创建的账号以 Cloudflare 账户名命名
|
||||
const newName = node.name.startsWith("Cloudflare 账号") ? account.name : node.name;
|
||||
await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(account.id, newName, nowIso(), input.nodeId)
|
||||
const newName = account.name.startsWith("Cloudflare 账号") ? cfAccount.name : account.name;
|
||||
await env.DB.prepare(
|
||||
"UPDATE cloudflare_accounts SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(cfAccount.id, newName, nowIso(), input.accountId)
|
||||
.run();
|
||||
const zones = await cf.listZones(input.accessToken, account.id);
|
||||
const zones = await cf.listZones(input.accessToken, cfAccount.id);
|
||||
await mark("discover", "done");
|
||||
|
||||
// 4. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
|
||||
// 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
|
||||
await mark("domains", "running");
|
||||
for (const zone of zones) {
|
||||
await upsertDomain(env, {
|
||||
cloudflareAccountId: account.id,
|
||||
nodeId: input.nodeId,
|
||||
cloudflareAccountId: cfAccount.id,
|
||||
accountId: input.accountId,
|
||||
name: zone.name,
|
||||
zoneId: zone.id,
|
||||
workerId: null,
|
||||
});
|
||||
}
|
||||
const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE node_id = ?")
|
||||
.bind(input.nodeId)
|
||||
const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE account_id = ?")
|
||||
.bind(input.accountId)
|
||||
.all<{ id: string; name: string }>();
|
||||
for (const d of domainRows.results) {
|
||||
await upsertMailbox(env, {
|
||||
@@ -204,8 +171,13 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
|
||||
}
|
||||
await mark("domains", "done");
|
||||
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
// 4. 部署账号级 Email Worker + 签发 Worker credential(docs/11 §14)
|
||||
await mark("worker", "running");
|
||||
await deployAccountWorker(env, input.accountId, input.accessToken);
|
||||
await mark("worker", "done");
|
||||
|
||||
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'active', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.accountId)
|
||||
.run();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
@@ -217,52 +189,104 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
|
||||
break;
|
||||
}
|
||||
}
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'error', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'error', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.accountId)
|
||||
.run();
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 为单个域名配置 Email Workers:部署账号级 Email Worker -> 开启 Email Routing ->
|
||||
* 部署(或更新)账号级 Email Worker:
|
||||
* 确保 Worker credential -> 渲染脚本(Console 地址 + Worker ID + secret)-> 上传 Cloudflare。
|
||||
* credential 已存在时复用旧 secret(重新上传脚本不轮换密钥);secret 仅在首次签发时用于部署。
|
||||
*/
|
||||
export async function deployAccountWorker(env: Env, accountId: string, accessToken: string): Promise<string> {
|
||||
const account = await env.DB.prepare("SELECT id, cloudflare_account_id FROM cloudflare_accounts WHERE id = ?")
|
||||
.bind(accountId)
|
||||
.first<{ id: string; cloudflare_account_id: string | null }>();
|
||||
if (!account?.cloudflare_account_id) throw new Error("account not connected via OAuth");
|
||||
|
||||
const workerId = await getOrCreateWorkerId(env, accountId);
|
||||
const { secret } = await ensureWorkerCredential(env, accountId, workerId);
|
||||
if (!secret) {
|
||||
// 已有 credential:重新上传脚本时需要原 secret,先解密
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT secret_enc FROM worker_credentials WHERE cloudflare_account_id = ? AND worker_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(accountId, workerId)
|
||||
.first<{ secret_enc: string }>();
|
||||
const decrypted = row ? await decryptSecret(env, row.secret_enc) : null;
|
||||
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, decrypted ?? "");
|
||||
return workerId;
|
||||
}
|
||||
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, secret);
|
||||
return workerId;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 账号的 worker_id(幂等:无则生成并写入 cloudflare_accounts.worker_id)
|
||||
*/
|
||||
async function getOrCreateWorkerId(env: Env, accountId: string): Promise<string> {
|
||||
const row = await env.DB.prepare("SELECT worker_id FROM cloudflare_accounts WHERE id = ?")
|
||||
.bind(accountId)
|
||||
.first<{ worker_id: string | null }>();
|
||||
if (row?.worker_id) return row.worker_id;
|
||||
const workerId = emailWorkerNameFor(accountId);
|
||||
await env.DB.prepare("UPDATE cloudflare_accounts SET worker_id = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(workerId, nowIso(), accountId)
|
||||
.run();
|
||||
return workerId;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 上传 Email Worker 脚本(Console 地址取 PUBLIC_BASE_URL,与 OAuth redirect 同源)
|
||||
*/
|
||||
async function putWorkerScript(
|
||||
env: Env,
|
||||
accessToken: string,
|
||||
cfAccountId: string,
|
||||
workerId: string,
|
||||
workerSecret: string,
|
||||
): Promise<void> {
|
||||
const cf: CFApi = getCFApi(env, env.DB);
|
||||
const consoleUrl = env.PUBLIC_BASE_URL ?? "";
|
||||
if (!consoleUrl) throw new Error("console public url unknown (PUBLIC_BASE_URL)");
|
||||
await cf.putEmailWorker({ accessToken, accountId: cfAccountId, scriptName: workerId, consoleUrl, workerId, workerSecret });
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 为单个域名配置 Email Workers:确保账号级 Worker 部署 -> 开启 Email Routing ->
|
||||
* catch-all 规则指向 Worker -> 域名置 active。幂等,失败信息写入 domains.setup_error。
|
||||
*/
|
||||
export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const domain = await env.DB.prepare(
|
||||
"SELECT id, name, zone_id, node_id FROM domains WHERE id = ?",
|
||||
"SELECT id, name, zone_id, account_id FROM domains WHERE id = ?",
|
||||
)
|
||||
.bind(domainId)
|
||||
.first<{ id: string; name: string; zone_id: string | null; node_id: string | null }>();
|
||||
.first<{ id: string; name: string; zone_id: string | null; account_id: string | null }>();
|
||||
if (!domain) return { ok: false, error: "domain not found" };
|
||||
if (!domain.node_id || !domain.zone_id) return { ok: false, error: "domain not bound to a Cloudflare zone" };
|
||||
if (!domain.account_id || !domain.zone_id) return { ok: false, error: "domain not bound to a Cloudflare zone" };
|
||||
|
||||
const node = await env.DB.prepare(
|
||||
"SELECT id, cloudflare_account_id, ingress_url, ingress_secret_enc FROM nodes WHERE id = ? AND status != 'revoked'",
|
||||
const account = await env.DB.prepare(
|
||||
"SELECT id, cloudflare_account_id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'",
|
||||
)
|
||||
.bind(domain.node_id)
|
||||
.first<{ id: string; cloudflare_account_id: string | null; ingress_url: string; ingress_secret_enc: string }>();
|
||||
if (!node?.cloudflare_account_id) return { ok: false, error: "account not connected via OAuth" };
|
||||
.bind(domain.account_id)
|
||||
.first<{ id: string; cloudflare_account_id: string | null }>();
|
||||
if (!account?.cloudflare_account_id) return { ok: false, error: "account not connected via OAuth" };
|
||||
|
||||
const accessToken = await decryptConnectionToken(env);
|
||||
if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" };
|
||||
|
||||
const cf: CFApi = getCFApi(env, env.DB);
|
||||
try {
|
||||
const workerName = emailWorkerNameFor(node.id);
|
||||
await cf.putEmailWorker({
|
||||
accessToken,
|
||||
accountId: node.cloudflare_account_id,
|
||||
scriptName: workerName,
|
||||
ingressUrl: node.ingress_url,
|
||||
ingressSecret: await decryptSecret(env, node.ingress_secret_enc),
|
||||
});
|
||||
const workerId = await deployAccountWorker(env, account.id, accessToken);
|
||||
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });
|
||||
await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName });
|
||||
await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName: workerId });
|
||||
await env.DB.prepare(
|
||||
"UPDATE domains SET worker_id = ?, status = 'active', setup_error = NULL, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(workerName, nowIso(), domainId)
|
||||
.bind(workerId, nowIso(), domainId)
|
||||
.run();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
@@ -275,11 +299,11 @@ export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取并解析 node 的步骤状态
|
||||
* 读取并解析账号的步骤状态
|
||||
*/
|
||||
export async function loadSteps(env: Env, nodeId: string) {
|
||||
const row = await env.DB.prepare("SELECT provision_steps FROM nodes WHERE id = ?")
|
||||
.bind(nodeId)
|
||||
export async function loadSteps(env: Env, accountId: string) {
|
||||
const row = await env.DB.prepare("SELECT provision_steps FROM cloudflare_accounts WHERE id = ?")
|
||||
.bind(accountId)
|
||||
.first<{ provision_steps: string }>();
|
||||
if (!row?.provision_steps) return initialSteps();
|
||||
try {
|
||||
@@ -290,13 +314,13 @@ export async function loadSteps(env: Env, nodeId: string) {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* upsert Domain(按 account+name 幂等)。无 workerId 时保持/置为 pending(待配置 Email Workers)。
|
||||
* upsert Domain(按 cf account+name 幂等)。无 workerId 时保持/置为 pending(待配置 Email Workers)。
|
||||
*/
|
||||
export async function upsertDomain(
|
||||
env: Env,
|
||||
d: {
|
||||
cloudflareAccountId: string;
|
||||
nodeId: string;
|
||||
accountId: string;
|
||||
name: string;
|
||||
zoneId: string;
|
||||
workerId: string | null;
|
||||
@@ -311,23 +335,23 @@ export async function upsertDomain(
|
||||
if (existing) {
|
||||
// 已配置过 Worker 的域名不回退状态;仅当新配置带 worker 时更新
|
||||
if (existing.worker_id || !d.workerId) {
|
||||
await env.DB.prepare("UPDATE domains SET node_id = ?, zone_id = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(d.nodeId, d.zoneId, now, existing.id)
|
||||
await env.DB.prepare("UPDATE domains SET account_id = ?, zone_id = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(d.accountId, d.zoneId, now, existing.id)
|
||||
.run();
|
||||
} else {
|
||||
await env.DB.prepare(
|
||||
"UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
|
||||
"UPDATE domains SET account_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(d.nodeId, d.zoneId, d.workerId, now, existing.id)
|
||||
.bind(d.accountId, d.zoneId, d.workerId, now, existing.id)
|
||||
.run();
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
const id = ids.domain();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId ? "active" : "pending", d.workerId, d.zoneId, now, now)
|
||||
.bind(id, d.cloudflareAccountId, d.accountId, d.name, d.workerId ? "active" : "pending", d.workerId, d.zoneId, now, now)
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user