diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index c9224de..9693fff 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -67,6 +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 | > 默认写在 `wrangler.toml` 的 `[vars]`(**明文例外**,因为当前值只是公开可达的 https origin, > 不是凭据)。一旦要放内网地址、带内嵌凭据的 URL、或不想公开的 staging 主机名, diff --git a/cloudflare-worker/src/health.js b/cloudflare-worker/src/health.js index 16a10be..19d2df5 100644 --- a/cloudflare-worker/src/health.js +++ b/cloudflare-worker/src/health.js @@ -67,13 +67,15 @@ export async function aggregateHealth(origins, ctx) { /** * 组装 LB 级 `/healthz` 响应:有 ≥1 后端存活 → 200;全 down → 503;无配置 → 200 + 明确标记。 * @param {{available:number,total:number,backends:unknown[]}} summary + * @param {string} [version] 版本标识(env.LB_VERSION);便于确认线上是哪次部署在回答。 + * 省略时 JSON 不含该字段(JSON.stringify 丢弃 undefined),保持向后兼容。 * @returns {Response} */ -export function healthResponse(summary) { +export function healthResponse(summary, version) { const healthy = summary.total > 0 && summary.available > 0; const status = summary.total === 0 ? "no-backends" : healthy ? "ok" : "down"; - return new Response(JSON.stringify({ status, ...summary }), { + return new Response(JSON.stringify({ status, version, ...summary }), { status: summary.total === 0 ? 200 : healthy ? 200 : 503, headers: { "content-type": "application/json" }, }); diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js index faa5936..7c9eb4d 100644 --- a/cloudflare-worker/src/index.js +++ b/cloudflare-worker/src/index.js @@ -132,14 +132,15 @@ export async function handleFetch(request, env) { /** 透传路由的统一错误/兜底日志不打印任何 header/body。 */ async function handleHealth(env) { + const version = lbVersion(env); let origins; try { origins = parseBackendOrigins(env.BACKEND_ORIGINS_JSON).map((o) => o.url); } catch { - return healthResponse({ available: 0, total: 0, backends: [] }); + return healthResponse({ available: 0, total: 0, backends: [] }, version); } if (origins.length === 0) { - return healthResponse({ available: 0, total: 0, backends: [] }); + return healthResponse({ available: 0, total: 0, backends: [] }, version); } const summary = await aggregateHealth(origins, { fetch: lazyFetch(), @@ -147,7 +148,17 @@ async function handleHealth(env) { ttlMs: intFromEnv(env.LB_HEALTH_TTL_MS, DEFAULT_HEALTH_TTL_MS), cache: healthCache, }); - return healthResponse(summary); + return healthResponse(summary, version); +} + +/** + * `/healthz` 的版本标识:确认线上是哪次部署在回答(`env.LB_VERSION`,写在 + * `wrangler.toml` 的 `[vars]`,随 git 变动;每次改 LB 逻辑的提交同时 bump)。 + * 不进 `[vars]` 的理由:它必须与代码同版本、可 diff,dashboard 里的手写值反而容易漂。 + * 未配置回退 `unknown`——探针永远不因缺这个字段而失败。 + */ +function lbVersion(env) { + return env.LB_VERSION ?? "unknown"; } /** 惰性绑定 globalThis.fetch,避免解构时丢失 this。 */ diff --git a/cloudflare-worker/test/health.test.js b/cloudflare-worker/test/health.test.js index 04cd3af..1cae216 100644 --- a/cloudflare-worker/test/health.test.js +++ b/cloudflare-worker/test/health.test.js @@ -47,6 +47,18 @@ test("aggregateHealth: all up => 200 ok summary", async () => { assert.equal(body.total, 2); }); +test("healthResponse: version 回显;未传时字段整个消失", async () => { + const summary = { available: 1, total: 1, backends: [] }; + + const withVersion = await healthResponse(summary, "2026.10.1").json(); + assert.equal(withVersion.version, "2026.10.1"); + assert.equal(withVersion.status, "ok"); + + // 未传 version 时字段必须整体消失(而非 "undefined"),保持向后兼容。 + const without = await healthResponse(summary).json(); + assert.equal("version" in without, false); +}); + test("aggregateHealth: partial down => still reports status (partial ok)", async () => { const ctx = mockCtx({ "https://a.example/healthz": { status: 200 }, diff --git a/cloudflare-worker/test/index.test.js b/cloudflare-worker/test/index.test.js index ab825d4..caba039 100644 --- a/cloudflare-worker/test/index.test.js +++ b/cloudflare-worker/test/index.test.js @@ -35,6 +35,16 @@ test("Worker GET /healthz aggregates backend probes without touching forwarded r assert.equal(body.available, 2); assert.equal(body.total, 2); assert.ok(Array.isArray(body.backends)); + // 未配置 LB_VERSION 时回退 "unknown"——探针绝不因缺这个字段而失败。 + assert.equal(body.version, "unknown"); + + // 配置后原样回显:确认线上是哪次部署在回答。 + const res2 = await handleFetch( + new Request("https://lb.example/healthz", { method: "GET" }), + makeEnv({ LB_VERSION: "2026.10.1" }), + ); + const body2 = await res2.json(); + assert.equal(body2.version, "2026.10.1"); } finally { restore(); } diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml index df904b7..04c3569 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -36,10 +36,14 @@ compatibility_flags = ["nodejs_compat_v2"] # LB_REQUEST_TIMEOUT_MS 可选,单 origin 超时(默认 10000,建议 > 最坏 cold start) # LB_MAX_ATTEMPTS 可选,每请求最多 origin 尝试次数(默认 2:首次 + 1 次故障转移, # 对应 deployment.md §10.4「有界重试其它实例(建议 1 次)」) +# LB_VERSION 版本标识,回显在 GET /healthz 的 JSON 里;用于确认线上是哪次部署 +# 在回答(改 LB 逻辑的提交同时 bump 它) # # ⚠️ 明文例外的边界:[vars] 里的 origin 只能是**公开可达的 https origin**(当前就是)。 # 一旦出现内网地址、带内嵌凭据的 URL、或不想公开的 staging 主机名,立刻从 [vars] 删掉, # 改用 `npx wrangler secret put BACKEND_ORIGINS_JSON`(git 历史不可逆,删掉不等于没进过库)。 -# 其余三个 LB_* 保持可调、不写死,用 dashboard 的 Add Variable 覆盖默认值即可。 +# 其余三个 LB_* 调参保持可调、不写死,用 dashboard 的 Add Variable 覆盖默认值即可; +# LB_VERSION 是例外——它是版本标识,必须跟代码同版本、可 diff,写进 dashboard 反而容易漂。 [vars] BACKEND_ORIGINS_JSON = '["https://messageweave-eu-1.motofans.club"]' +LB_VERSION = "2026.10.1" diff --git a/docs/deployment.md b/docs/deployment.md index bd128b2..584a47c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -737,7 +737,7 @@ 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, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。 +- **健康聚合(`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),不触发业务副作用。 - **故障转移(`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,6 +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" ``` **明文例外及其边界**:origin 只是公开可达的 https 地址,不是凭据,所以可以随仓库走(好处是 @@ -827,7 +828,7 @@ npx wrangler secret put BACKEND_ORIGINS_JSON 这条已修掉:`wrangler.toml` 现已全部展开成 `#` 行,`wrangler deploy --dry-run` 通过(上传 31.48 KiB)。 2. **`npm test` 在 Node 22.23 上原先跑不起来**:脚本写的是 `node --test test/`,Node 会把 `test/` 当成模块路径去 require,报 `MODULE_NOT_FOUND`。已改成 `node --test`(不加参数自动发现 `test/`), - 仍为 32 个用例全通过(`cloudflare-worker/test/backends.test.js` 6 / `cloudflare-worker/test/health.test.js` 4 / `cloudflare-worker/test/index.test.js` 12 / `cloudflare-worker/test/lb.test.js` 10)。 + 当前为 35 个用例全通过(`cloudflare-worker/test/backends.test.js` 6 / `cloudflare-worker/test/health.test.js` 5 / `cloudflare-worker/test/index.test.js` 12 / `cloudflare-worker/test/lb.test.js` 12;lb 含 2 条不可变响应头回归,health 含 1 条 `version` 回显)。 **部署与验证** @@ -843,7 +844,8 @@ curl https://.workers.dev/healthz **验证(发布前必做)** 1. `cd cloudflare-worker && npm test`(Node 单测,代理逻辑 + 健康聚合 + 超时/失败重试 + safelist)。 2. `npx wrangler deploy --env production` 后 `curl /healthz` 看**响应体里的 `available`**: - 必须 `available ≥ 1` 才算接通。 + 必须 `available ≥ 1` 才算接通;同时核对 `version` 等于 `wrangler.toml` 里的 `LB_VERSION`, + 不等就说明跑的还是旧部署。 ⚠️ **只看 HTTP 200 会误判**:`/healthz` 是 Worker 自己生成的(不透传后端,`cloudflare-worker/src/index.js:75`), 而且 `BACKEND_ORIGINS_JSON` 缺失、格式错或为空时,`handleHealth` 会吞掉解析异常并返回 **HTTP 200 + `{"status":"no-backends","available":0,"total":0}`**(`cloudflare-worker/src/index.js:138` 的 catch)。