diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 9693fff..af39966 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -3,7 +3,7 @@ 统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。 **透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。 -> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 16 条白名单、 +> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 17 条白名单、 > 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与 > 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。 @@ -67,7 +67,7 @@ | `LB_MAX_ATTEMPTS` | 否 | 每请求最多 origin 尝试次数;默认 `2`(= 1 次故障转移)。`POST /reconcile` 固定为 `1`。 | | `LB_RECONCILE_TIMEOUT_MS` | 否 | 仅 `POST /reconcile` 的单 origin 超时覆盖;默认 `320000`(须大于后端锁初租 300s + 心跳余量)。 | | `LB_HEALTH_TTL_MS` | 否 | 健康探测缓存 TTL;默认 `30000`。 | -| `LB_VERSION` | 否 | `/healthz` 响应体里的 `version` 字段,用于确认线上是哪次部署在回答;取自 `wrangler.toml` 的 `[vars]`(当前 `2026.10.1`),改 LB 逻辑的提交同时 bump | +| `LB_VERSION` | 否 | `/healthz-worker` 响应体里的 `version` 字段,用于确认线上是哪次部署在回答;取自 `wrangler.toml` 的 `[vars]`(当前 `2026.10.2`),改 LB 逻辑的提交同时 bump | > 默认写在 `wrangler.toml` 的 `[vars]`(**明文例外**,因为当前值只是公开可达的 https origin, > 不是凭据)。一旦要放内网地址、带内嵌凭据的 URL、或不想公开的 staging 主机名, diff --git a/cloudflare-worker/src/backends.js b/cloudflare-worker/src/backends.js index f27321d..ae4a687 100644 --- a/cloudflare-worker/src/backends.js +++ b/cloudflare-worker/src/backends.js @@ -4,7 +4,8 @@ * 仅 ES2022,零依赖,可 node 直接执行。 */ -/** 允许透传的入站路由 safelist(C-LB-SINGLE-REG-URL;/healthz 由 Worker 自身聚合,不在列):未知路径 → 404。 +/** 允许透传的入站路由 safelist(C-LB-SINGLE-REG-URL):未知路径 → 404。 + * `/healthz` 透传到源站(源站自己的健康检查);LB 自己的聚合探针在 `/healthz-worker`,不在列。 * SPA 静态资源为公开 GET;所有管理 API 仍由后端校验 Redis bootstrap 凭据或 admin session。 */ export const SAFE_ROUTES = Object.freeze([ "/", @@ -22,6 +23,7 @@ export const SAFE_ROUTES = Object.freeze([ "/api/push/register", "/api/push/disable", "/reconcile", + "/healthz", "/ready", ]); diff --git a/cloudflare-worker/src/health.js b/cloudflare-worker/src/health.js index 19d2df5..deb16e4 100644 --- a/cloudflare-worker/src/health.js +++ b/cloudflare-worker/src/health.js @@ -2,7 +2,8 @@ * 健康聚合探针(稳定 ID:MOD-HEALTH-AGG │ C-NO-DB │ C-REDIS-ONLY-STATE)。 * * 语义: - * - Worker 暴露 LB 级 `GET /healthz`,聚合探测各后端 origin 的 `/healthz`(后端公开探针,无敏感信息)。 + * - Worker 暴露 LB 级 `GET /healthz-worker`,聚合探测各后端 origin 的 `/healthz` + * (后端公开探针,无敏感信息);入站 `/healthz` 保留给源站自己的健康检查(透传)。 * - 缓存 TTL(默认 30秒)避免健康探测放大;TTL 内复用上次结果,过期后才重探。 * - 探测失败/超时 → 该 origin 记为 down;`available` 统计存活数。 * - 仅 HTTP 层健康(<500 记为 up):**不**检查/代理 Redis、JMAP(C-NO-DB / C-REDIS-ONLY-STATE), @@ -65,7 +66,7 @@ export async function aggregateHealth(origins, ctx) { } /** - * 组装 LB 级 `/healthz` 响应:有 ≥1 后端存活 → 200;全 down → 503;无配置 → 200 + 明确标记。 + * 组装 LB 级 `/healthz-worker` 响应:有 ≥1 后端存活 → 200;全 down → 503;无配置 → 200 + 明确标记。 * @param {{available:number,total:number,backends:unknown[]}} summary * @param {string} [version] 版本标识(env.LB_VERSION);便于确认线上是哪次部署在回答。 * 省略时 JSON 不含该字段(JSON.stringify 丢弃 undefined),保持向后兼容。 diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js index 7c9eb4d..1be4c31 100644 --- a/cloudflare-worker/src/index.js +++ b/cloudflare-worker/src/index.js @@ -5,7 +5,7 @@ * - SAF-LB-PASSTHRU:透传 headers/body(含鉴权头),不做鉴权改写。 * - C-HTTPS-INBOUND:仅 https 后端 origin。 * - ARCH-LB-WORKER / C-LB-SINGLE-REG-URL:safelist 路由,未知路径 404、method 不符 405。 - * - MOD-HEALTH-AGG:/healthz 由 Worker 聚合,不落后端。 + * - MOD-HEALTH-AGG:/healthz-worker 由 Worker 聚合;/healthz 透传到源站。 * - C-NO-DB / C-REDIS-ONLY-STATE:不代理 Redis/JMAP;不检查数据库侧可用性。 * - C-NO-LONG-CONN:纯请求-响应,body 一次性读入回灌;无 WS/SSE/长轮询。 * - C-NO-SECRET-IN-IMAGE:日志仅「方法/路径/origin/失败类别」,不含 secret;origin 清单走 wrangler secret。 @@ -16,7 +16,8 @@ * - GET|PUT /api/config、GET|PUT /api/business-config、POST /api/business-config/preflight、 * POST /api/admin/session[/revoke] * → 透传至后端;后端校验 bootstrap 凭据或短期 admin session - * - GET /healthz → LB 聚合健康(MOD-HEALTH-AGG) + * - GET /healthz → 透传到源站(源站自己的健康检查) + * - GET /healthz-worker → LB 聚合健康(MOD-HEALTH-AGG),响应体带 LB_VERSION * - POST /webhook/tg|/push/jmap|/api/push/register|/api/push/disable|/reconcile、GET /ready → 透传 + 有界故障转移 * - 其它 → 404 / 405 * @@ -38,7 +39,7 @@ const DEFAULT_RECONCILE_TIMEOUT_MS = 320_000; /** * 健康探测缓存(模块作用域 = CF Worker isolate 内跨请求复用)。 * 仅存「origin → {at,up,status}」,不含任何 header/body/secret(SAF-LOG-PURITY 精神)。 - * TTL 由 LB_HEALTH_TTL_MS 控制;isolate 冷启时为空,首个 /healthz 全量探测。 + * TTL 由 LB_HEALTH_TTL_MS 控制;isolate 冷启时为空,首个 /healthz-worker 全量探测。 */ const healthCache = new Map(); @@ -59,6 +60,7 @@ const ROUTE_METHODS = Object.freeze({ "/api/push/register": ["POST"], "/api/push/disable": ["POST"], "/reconcile": ["POST"], + "/healthz": ["GET"], "/ready": ["GET"], }); @@ -72,9 +74,11 @@ export async function handleFetch(request, env) { const path = new URL(request.url).pathname; // LB 级健康聚合探针(不含敏感信息,SAF-PROBE-PUBLIC 精神)。 - if (path === "/healthz") { + // 挂在 /healthz-worker:/healthz 保留给源站自己的健康检查(透传),这样入站域名上 + // 两个健康检查互不遮蔽、能分别看到 LB 与源站的版本。 + if (path === "/healthz-worker") { if (request.method !== "GET") { - return text("method not allowed for /healthz", 405, { allow: "GET" }); + return text("method not allowed for /healthz-worker", 405, { allow: "GET" }); } return handleHealth(env); } diff --git a/cloudflare-worker/test/backends.test.js b/cloudflare-worker/test/backends.test.js index af915b1..a688c7b 100644 --- a/cloudflare-worker/test/backends.test.js +++ b/cloudflare-worker/test/backends.test.js @@ -42,16 +42,16 @@ test("BACKEND_ORIGINS_JSON parses, dedups, validates all entries", () => { assert.throws(() => parseBackendOrigins("[]"), /non-empty/); }); -test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)", () => { +test("route safelist forwards /healthz and blocks unknown paths (C-LB-SINGLE-REG-URL)", () => { for (const r of [ "/", "/assets/config.js", "/assets/styles.css", "/api/status", "/ready", "/webhook/tg", "/push/jmap", "/reconcile", "/api/push/register", "/api/push/disable", "/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke", - "/api/enabled", + "/api/enabled", "/healthz", ]) { assert.ok(LB_ROUTES.includes(r), `forwarded: ${r}`); } - for (const r of ["/healthz", "/admin", "/debug", "/jmap/session", "/ready/extra"]) { + for (const r of ["/healthz-worker", "/admin", "/debug", "/jmap/session", "/ready/extra"]) { assert.ok(!LB_ROUTES.includes(r), `must be blocked: ${r}`); assert.ok(!SAFE_ROUTES.includes(r), `must not be a safe route: ${r}`); } diff --git a/cloudflare-worker/test/index.test.js b/cloudflare-worker/test/index.test.js index caba039..e7aef6b 100644 --- a/cloudflare-worker/test/index.test.js +++ b/cloudflare-worker/test/index.test.js @@ -1,6 +1,6 @@ /** * 集成测试:Cloudflare Worker 主入口 handle fetch(ARCH-LB-WORKER / SAF-LB-PASSTHRU)。 - * 验证:路由分发、/healthz 聚合、未白名单 404、method 拦截、配置异常 fail-closed、鉴权头透传。 + * 验证:路由分发、/healthz-worker 聚合、/healthz 透传、未白名单 404、method 拦截、配置异常 fail-closed、鉴权头透传。 * 运行:cd cloudflare-worker && node --test */ import test from "node:test"; @@ -25,10 +25,10 @@ function stubFetch(impl) { }; } -test("Worker GET /healthz aggregates backend probes without touching forwarded routes", async () => { +test("Worker GET /healthz-worker aggregates backend probes without touching forwarded routes", async () => { const restore = stubFetch(async (url) => new Response("ok", { status: 200 })); try { - const res = await handleFetch(new Request("https://lb.example/healthz", { method: "GET" }), makeEnv()); + const res = await handleFetch(new Request("https://lb.example/healthz-worker", { method: "GET" }), makeEnv()); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.status, "ok"); @@ -40,7 +40,7 @@ test("Worker GET /healthz aggregates backend probes without touching forwarded r // 配置后原样回显:确认线上是哪次部署在回答。 const res2 = await handleFetch( - new Request("https://lb.example/healthz", { method: "GET" }), + new Request("https://lb.example/healthz-worker", { method: "GET" }), makeEnv({ LB_VERSION: "2026.10.1" }), ); const body2 = await res2.json(); @@ -50,15 +50,30 @@ test("Worker GET /healthz aggregates backend probes without touching forwarded r } }); -test("Worker: wrong method on the local /healthz probe => 405, not 404", async () => { +test("Worker: wrong method on the local /healthz-worker probe => 405, not 404", async () => { const res = await handleFetch( - new Request("https://lb.example/healthz", { method: "POST", body: "{}" }), + new Request("https://lb.example/healthz-worker", { method: "POST", body: "{}" }), makeEnv(), ); assert.equal(res.status, 405); assert.equal(res.headers.get("allow"), "GET"); }); +test("Worker GET /healthz is forwarded to the origin, not answered by the LB", async () => { + // 两个健康检查共存:/healthz 属于源站,/healthz-worker 属于 LB。 + const restore = stubFetch(async () => new Response("origin-ok", { status: 200 })); + try { + const res = await handleFetch( + new Request("https://lb.example/healthz", { method: "GET" }), + makeEnv(), + ); + assert.equal(res.status, 200); + assert.equal(await res.text(), "origin-ok"); + } finally { + restore(); + } +}); + test("Worker: unknown route => 404 (C-LB-SINGLE-REG-URL)", async () => { const res = await handleFetch( new Request("https://lb.example/unknown", { method: "POST", body: "{}" }), diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml index 04c3569..a698194 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -46,4 +46,4 @@ compatibility_flags = ["nodejs_compat_v2"] # LB_VERSION 是例外——它是版本标识,必须跟代码同版本、可 diff,写进 dashboard 反而容易漂。 [vars] BACKEND_ORIGINS_JSON = '["https://messageweave-eu-1.motofans.club"]' -LB_VERSION = "2026.10.1" +LB_VERSION = "2026.10.2" diff --git a/docs/charter.md b/docs/charter.md index 9931346..d647d71 100644 --- a/docs/charter.md +++ b/docs/charter.md @@ -274,7 +274,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查 | `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | 双因子门禁:「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭。开启信号走 env 而非 argv,使启动命令保持静态、开关可在平台控制台单点切换 | 安全 | | `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 | | `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 / docs/deployment.md §2.1 | 端点契约:`GET /debug/ping`、`/config`、`/redis`、`/jmap`、`/telegram`、`/worker` 均只读;`POST /debug/notify` 走真实出站链路发一条测试消息;响应体不含 secret 原文(凭据字段只出 `*_configured` 布尔,非密文的身份与预算字段仍明文返回) | 需求 | -| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在网关 16 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 | +| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在网关 17 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 | | `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` 仅在 chat 白名单**非空**时校验 `chat_id`;白名单未配置(空)时不拦截,故启用本面须确认业务白名单已配置 | 安全 | | `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 | | `C-NO-DB` | docs/deployment.md §0 / §9.1 / §3 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 | diff --git a/docs/deployment.md b/docs/deployment.md index 584a47c..afeac0a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -102,7 +102,7 @@ - **绝不回显凭据值**:`debug_config`(`src/debug.rs:96-152`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。 - **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1563`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:257-260`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:265-270`)。 -- **不在 Worker 白名单内,只能直连 origin**:网关的 16 条安全路由(`cloudflare-worker/src/backends.js:9-26`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:82-85`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。 +- **不在 Worker 白名单内,只能直连 origin**:网关的 17 条安全路由(`cloudflare-worker/src/backends.js:10-28`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:87-89`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。 > **建议**:生产环境不开启。需要远程联调时在控制台设置 `DEBUG_ENABLED=1` 与一次性 > `DEBUG_TOKEN`,重新部署后联调;结束立即把 `DEBUG_ENABLED` 置空并轮换 `DEBUG_TOKEN`。 @@ -138,7 +138,7 @@ - 非 root 用户运行(保留既有约定) - **公网 HTTPS 入口(`C-HTTPS-URL` 已确认)**:运维在平台上给 bot 配一个公网 HTTPS URL(如 `https://bot.example.com`),平台 ingress/反代把 `https://…/webhook/tg`、`/push/jmap`、`/reconcile` 路由到容器 `PORT`。**bot 自身不申请证书、不监听 443**;证书由平台/反代管理(`C-HTTPS-INBOUND`)。 - 需要在 Stalwart 与 Telegram 两侧使用这个公网 URL:Telegram `setWebhook` 指向 `/webhook/tg`;管理员调用 `POST /api/push/register` 并提交该 URL 的 `/push/jmap` 路径,后端负责执行 `PushSubscription/set create`。服务不会自动猜测平台公网域名。 -- **出站 egress 是 `/ready` 的硬依赖**:就绪探针向配置的 JMAP session host 与 `api.telegram.org:443` 发起只读 `GET`。该 egress 不通时 `/ready` 会长期 `503`,即使配置与 Redis 都正常。若 egress 必须经 HTTP(S) 代理则**无法使用**:应用内 `reqwest` 以 `default-features = false` 编译(`Cargo.toml:12`),未启用 `proxy` feature,**不解析 `HTTPS_PROXY`/`HTTP_PROXY`**;这种情况下请用网关聚合的 `/healthz`(无条件 200)作为存活监控,`/ready` 仅作人工排查。 +- **出站 egress 是 `/ready` 的硬依赖**:就绪探针向配置的 JMAP session host 与 `api.telegram.org:443` 发起只读 `GET`。该 egress 不通时 `/ready` 会长期 `503`,即使配置与 Redis 都正常。若 egress 必须经 HTTP(S) 代理则**无法使用**:应用内 `reqwest` 以 `default-features = false` 编译(`Cargo.toml:12`),未启用 `proxy` feature,**不解析 `HTTPS_PROXY`/`HTTP_PROXY`**;这种情况下请用网关聚合的 `/healthz-worker`(无条件 200)作为存活监控,`/ready` 仅作人工排查。 - **多实例 LB/HA 时登记的是 Worker URL 而非各后端 URL**(`C-LB-SINGLE-REG-URL`,§10):Telegram / Stalwart / Cron 只认 Worker 的稳定域名;后端平台入口不对外登记。 - Secrets 运行期注入(环境变量/容器平台 secret),见 §5(同文件内章节链接) @@ -736,8 +736,8 @@ Stalwart PushSub ────┼─▶ https://lb..workers.dev ← 唯 ### 10.4 路由与故障转移 -- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **16 条**(`SAFE_ROUTES` at `backends.js:9-26` + `ROUTE_METHODS` at `index.js:46-63`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`GET|PUT /api/business-config`、`POST /api/business-config/preflight`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`GET|PUT /api/enabled`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 类不在 safelist**:`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(队列排空端点,由外部调度周期性调用而非人工触发,Bearer 鉴权,见 §6.3.1)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 4 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`GET|PUT /api/enabled` 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(`loadEnabled` 在 `web/config.js:676` 读、开关写入在 `:1048`),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 `/api/admin/session` 完全一致。`GET|PUT /api/business-config` 与 `POST /api/business-config/preflight` 同属此类必须透传的路由:SPA 由 Worker 服务,配置回读、提交前校验与保存都由它发起,缺了这两条,回读与预校验经网关部署时恒 404/405,整条配置管理页面只剩后端直连可用;两者同样强制 admin-session Bearer 鉴权,且回读只返回密钥存在性布尔(`SAF-NO-SECRET-ECHO`),不新增凭据暴露面。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 -- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, version, available, total, backends:[{origin,up,status}]}`(`version` 取 `LB_VERSION`,用于确认线上是哪次部署在回答);≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。 +- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **17 条**(`SAFE_ROUTES` at `backends.js:10-28` + `ROUTE_METHODS` at `index.js:47-65`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`GET|PUT /api/business-config`、`POST /api/business-config/preflight`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`GET|PUT /api/enabled`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`、`GET /healthz`(源站自身健康检查;LB 自己的聚合探针在 `/healthz-worker`,不在列);**未知路径 404、method 不符 405**,不透传至后端。**后端另有 3 类不在 safelist**:`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(队列排空端点,由外部调度周期性调用而非人工触发,Bearer 鉴权,见 §6.3.1)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 3 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`GET|PUT /api/enabled` 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(`loadEnabled` 在 `web/config.js:676` 读、开关写入在 `:1048`),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 `/api/admin/session` 完全一致。`GET|PUT /api/business-config` 与 `POST /api/business-config/preflight` 同属此类必须透传的路由:SPA 由 Worker 服务,配置回读、提交前校验与保存都由它发起,缺了这两条,回读与预校验经网关部署时恒 404/405,整条配置管理页面只剩后端直连可用;两者同样强制 admin-session Bearer 鉴权,且回读只返回密钥存在性布尔(`SAF-NO-SECRET-ECHO`),不新增凭据暴露面。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 +- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz-worker`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, version, available, total, backends:[{origin,up,status}]}`(`version` 取 `LB_VERSION`,用于确认线上是哪次部署在回答);≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。 - **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。**例外:`POST /reconcile` 固定 `maxAttempts=1`,绝不故障转移**——它持集群级单飞锁,切实例只会立刻撞 `409`;其单请求超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms),其余路由不受影响。 - **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。 - **全失败兜底**:返回 `503 All Backends Unavailable`,交由 Telegram / Stalwart 自动重投(**不丢消息**)。 @@ -793,7 +793,7 @@ npx wrangler secret put LB_HEALTH_TTL_MS # 默认 30000ms ```toml [vars] BACKEND_ORIGINS_JSON = '["https://messageweave-eu-1.motofans.club"]' -LB_VERSION = "2026.10.1" +LB_VERSION = "2026.10.2" ``` **明文例外及其边界**:origin 只是公开可达的 https 地址,不是凭据,所以可以随仓库走(好处是 @@ -836,17 +836,17 @@ npx wrangler secret put BACKEND_ORIGINS_JSON cd cloudflare-worker npx wrangler deploy --env production # 验证 LB 健康聚合 -curl https://.workers.dev/healthz +curl https://.workers.dev/healthz-worker ``` **Telegram / Stalwart / Cron 登记的唯一 URL** 改为 `https://.workers.dev/webhook/tg` 等(`C-LB-SINGLE-REG-URL`);**各后端平台的 HTTPS 入口不再对外登记**(小平台入口可能被公网直连,故**后端鉴权不可省**,`SAF-LB-PASSTHRU`)。 **验证(发布前必做)** 1. `cd cloudflare-worker && npm test`(Node 单测,代理逻辑 + 健康聚合 + 超时/失败重试 + safelist)。 -2. `npx wrangler deploy --env production` 后 `curl /healthz` 看**响应体里的 `available`**: +2. `npx wrangler deploy --env production` 后 `curl /healthz-worker` 看**响应体里的 `available`**: 必须 `available ≥ 1` 才算接通;同时核对 `version` 等于 `wrangler.toml` 里的 `LB_VERSION`, 不等就说明跑的还是旧部署。 - ⚠️ **只看 HTTP 200 会误判**:`/healthz` 是 Worker 自己生成的(不透传后端,`cloudflare-worker/src/index.js:75`), + ⚠️ **只看 HTTP 200 会误判**:`/healthz-worker` 是 Worker 自己生成的(不透传后端,`cloudflare-worker/src/index.js:78-82`), 而且 `BACKEND_ORIGINS_JSON` 缺失、格式错或为空时,`handleHealth` 会吞掉解析异常并返回 **HTTP 200 + `{"status":"no-backends","available":0,"total":0}`**(`cloudflare-worker/src/index.js:138` 的 catch)。 所以「200」本身不证明配置正确,必须看 `available`。判据速查: @@ -903,7 +903,7 @@ Git 集成**必须改的四个字段**(Cloudflare 的默认值对不上本仓 (8787 是 `wrangler dev` 的默认端口,`--ip 0.0.0.0` 让 Cloudflare 的预览代理能连进来)。 ⚠️ 但预览环境**拿不到** dashboard 里加的 secret:现在 `BACKEND_ORIGINS_JSON` 写在 `wrangler.toml` 的 `[vars]` 里,预览因此也能跑通转发;**一旦改回 secret 注入**,预览就只剩 - 「Worker 能被拉起 + 路由表生效 + `/healthz` 返回 200 但 `status` 是 `no-backends`」, + 「Worker 能被拉起 + 路由表生效 + `/healthz-worker` 返回 200 但 `status` 是 `no-backends`」, **不能**验证后端转发,转发只能看生产。(仓库里也不该有 `.dev.vars`,它只对 `wrangler dev` 生效。) **Node 版本不用管。** Cloudflare 环境实测给的是 `nodejs@24.18.0`,本地在 Node 24.21.0 与 @@ -925,7 +925,7 @@ Encrypt(填 secret)。改 secret **即时生效,不需要重新部署**。 任何明文 secret 提交进仓库(本仓库里没有,`.gitignore` 也排除了)。 **验证方式与 CLI 完全相同**,见上方「验证(发布前必做)」,尤其第 2 条: -看 `/healthz` 响应体的 `available`,不要只看 HTTP 200。 +看 `/healthz-worker` 响应体的 `available`,不要只看 HTTP 200。 --- diff --git a/docs/design.md b/docs/design.md index 54af8ee..4aa2888 100644 --- a/docs/design.md +++ b/docs/design.md @@ -577,7 +577,7 @@ pub enum BotError { **运行监控(`GATE-UPTIME-KUMA`):** - 使用 Uptime Kuma HTTP(s) Monitor 检查 `/healthz`(进程存活)和 `/ready`(配置 / Redis / 上游可达就绪),分别期望 HTTP 200;`/ready` 最坏约 3s,探针超时需设 ≥10s;`/ready` 不就绪时返回 `503` 与标准错误 envelope(`service_unavailable` + `Retry-After: 30`),Uptime Kuma 仍按状态码判定,不受响应体变化影响。 -- `/healthz` 是纯 liveness(无条件 `200`);`/ready` 检查配置完整性 + Redis 可达性 + 出站只读探测(JMAP session `GET`、TG `getMe`,各 3s、并行,最坏约 3s),探针只读、不回显 token 或第三方响应、不触发业务副作用,报告体不含敏感信息;由于 `/ready` 是最重的一环(可能 3s),平台侧应优先使用网关聚合的 `/healthz` 作为存活探测,避免高频出站请求。 +- `/healthz` 是纯 liveness(无条件 `200`);`/ready` 检查配置完整性 + Redis 可达性 + 出站只读探测(JMAP session `GET`、TG `getMe`,各 3s、并行,最坏约 3s),探针只读、不回显 token 或第三方响应、不触发业务副作用,报告体不含敏感信息;由于 `/ready` 是最重的一环(可能 3s),平台侧应优先使用网关聚合的 `/healthz-worker` 作为存活探测,避免高频出站请求。 - 不引入 Prometheus、Exporter 或额外指标端口;真实 Stalwart/Telegram 端到端链路仍需单独联调。 **生产红线(贯穿所有阶段)**: