diff --git a/README.md b/README.md index ad538ce..0c7fba0 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,39 @@ State is an architectural boundary here, not a preference. - **What it is not.** It is not a mail client, not a proxy, and not a relay. It forwards metadata to one bot; it does not read arbitrary URLs, open tunnels, or broker traffic. -## 2. 5-minute setup +## 2. Two ways to run it + +One image, one Redis, one SPA. The only difference is what sits in front and which URL you hand +to Telegram and Stalwart. + +| | Form A: backend origin only | Form B: behind the Cloudflare Worker gateway | +|---|---|---| +| What answers requests | one backend host | one Worker URL, N backend origins | +| Failover | none — a down origin stays down | bounded: 2 attempts by default, only timeout and `5xx` retry | +| Callback URL you register | `https://a.example` | `https://lb.example` | +| Reachable routes | every route, including `/api/bootstrap` and `/debug/*` | only the 19 safelisted paths | +| Health to watch | `GET /ready` on the origin | `GET /ready` proxied, plus `GET /healthz-worker` for the gateway aggregate | +| Extra moving parts | none | one Worker deploy, one origin list | +| Docs | `docs/deployment.md §2` | `docs/deployment.md §10`, `cloudflare-worker/README.md` | + +Pick Form A for a single origin with no HA requirement. Pick Form B for two or more origins, or +whenever you want one stable callback URL while you roll or rebuild an instance. + +Switching is a re-registration, not a migration. Registration is idempotent and registering a +new address retires the previous subscription, so moving from Form A to Form B, or back, is one +field in the SPA. Do not register both origins at once: there is a single registration slot, and +the platform entrance must stay internal (`C-LB-SINGLE-REG-URL`). + +No secret moves to the gateway in Form B. It holds no business credential and checks no auth +header. Instead every backend must carry the same `SAF-AUTH-*` secrets and the same encrypted +business config, because a callback can land on any instance and no instance can prove it was +the one asked for (`C-LB-SHARED-SECRETS`). Mis-matched secrets show up as a random 401, not as +a routing error. + +`POST /api/bootstrap` and `/debug/*` stay origin-only in both forms, so neither is exposed +through the gateway. + +## 3. 5-minute setup Requires a reachable Redis instance and a JMAP session plus a Telegram bot token. @@ -50,9 +82,9 @@ For local builds without Docker: `cargo build --locked` produces the `message-we If the service answers requests but does nothing useful, check the logs for a startup warning: a missing required variable does **not** crash the process. It serves a -read-only setup router over in-memory state instead. See §3. +read-only setup router over in-memory state instead. See §4. -## 3. Configuration entry points +## 4. Configuration entry points Three layers. They are not interchangeable. @@ -79,7 +111,8 @@ secrets from the environment in normal operation. returns 503 until configuration, Redis, and both upstreams (a JMAP session `GET` and Telegram `getMe`, 3s each) are reachable. Probe `/ready`, not `/healthz`, when deciding whether to route traffic — but `/ready` is the heavier probe (~3s worst case), so a monitor -that must stay cheap should watch the gateway's aggregated `/healthz`. +that must stay cheap should watch the gateway aggregate `GET /healthz-worker` when the gateway +is enabled. **Remote debug (opt-in, off by default).** `/debug/*` is an optional remote-debug surface: live JMAP and Telegram probes, the current @@ -92,7 +125,7 @@ unset. If you do turn it on for a one-off diagnosis, configure the chat allowlis an empty allowlist the test send is not restricted to any chat. See `docs/deployment.md` §2.1. -## 4. Security boundary, in one sentence +## 5. Security boundary, in one sentence > All state lives in Redis; the process reads only two environment variables and writes > nothing to disk. @@ -103,7 +136,7 @@ startup. It is only compared in constant time, and is never echoed, logged, or s Redis ACL password, if you have one, authenticates the Redis connection alone and is not the credential for any HTTP endpoint. -## 5. Where to read more +## 6. Where to read more | Document | What it answers | |---|---| @@ -113,6 +146,7 @@ the credential for any HTTP endpoint. | [`docs/opengaps.md`](docs/opengaps.md) | Gaps, blockers and the next phase | | [`docs/retired.md`](docs/retired.md) | What was tried and dropped — abandoned routes, unreleased designs, and names that never existed | | [`docs/charter.md`](docs/charter.md) | Project charter: goals, locked technology choices, security invariants, prohibitions, and the stable-ID registry | +| [`cloudflare-worker/README.md`](cloudflare-worker/README.md) | The Cloudflare Worker gateway: route safelist, timeout and retry budget, bounded failover, health aggregate, deploy | | [`AGENTS.md`](AGENTS.md) | Language-agnostic engineering norms: code style, config and secrets, build environment, testing gates, document governance | Facts that matter are traceable. If two documents disagree, `docs/reference.md` wins. diff --git a/README.zh-CN.md b/README.zh-CN.md index 366b4cd..5124a38 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -25,10 +25,34 @@ MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot - **它不是什么。** 它不是邮件客户端,不是代理,也不是转发中继。它只把一个 bot 的元数据 送达 Telegram;不抓取任意 URL、不开隧道、不中转流量。 -## 2. 五分钟跑起来 +## 2. 两种部署形态 + +同一个镜像、同一个 Redis、同一套 SPA。唯一的区别是前面放什么,以及你把哪个 URL 交给 Telegram 和 Stalwart。 + +| | 形态 A:仅后端 origin | 形态 B:前置 Cloudflare Worker 网关 | +|---|---|---| +| 谁应答请求 | 一个后端主机 | 一个 Worker URL 对 N 个后端 origin | +| 故障转移 | 没有——origin 挂了就一直挂着 | 有界——默认 2 次尝试,只重试超时与 `5xx` | +| 你注册的回调 URL | `https://a.example` | `https://lb.example` | +| 可达路由 | 全部路由,含 `/api/bootstrap` 与 `/debug/*` | 只有 19 个白名单路径 | +| 要看哪个健康接口 | origin 上的 `GET /ready` | 透传的 `GET /ready`,外加网关聚合 `GET /healthz-worker` | +| 额外组件 | 无 | 一个 Worker 部署、一份 origin 列表 | +| 文档 | `docs/deployment.md §2` | `docs/deployment.md §10`、`cloudflare-worker/README.zh-CN.md` | + +单 origin 且没有 HA 要求时选形态 A;两个及以上 origin、或想在滚动与重建某个实例时保持一个稳定的回调 URL,就选形态 B。 + +切换是一次重新注册,不是迁移。注册是幂等的,注册新地址会注销旧订阅,所以从形态 A 换到形态 B(或反过来)只是 SPA 里改一个字段。不要同时注册两个 origin:注册槽只有一个,平台入口必须保持内网(`C-LB-SINGLE-REG-URL`)。 + +形态 B 里没有任何 secret 搬到网关。它不持有任何业务凭据,也不校验任何认证头。真正要求一致的是每个后端都携带同一组 `SAF-AUTH-*` secret 与同一份加密业务配置——因为回调可能落在任何一个实例上,而任何实例都证明不了自己是被点名的那一个(`C-LB-SHARED-SECRETS`)。凭据不一致表现为随机 401,而不是路由错误。 + +`POST /api/bootstrap` 与 `/debug/*` 在两种形态下都只保留在 origin 直连,不经过网关暴露。 + +## 3. 五分钟跑起来 需要可用的 Redis 服务、一个 JMAP session 与一个 Telegram bot token。 +这段是**本地容器冒烟测试**。生产部署可以用任何能跑起后端容器、注入加密 secret 并连到外部托管 Redis 的平台,见 `docs/deployment.md` §3 与 `docs/reference.md` 的平台相关部分。 + ```sh cp .env.example .env # 填写 REDIS_URL 与 CONFIG_ENCRYPTION_KEY docker build -t messageweave:latest . @@ -42,13 +66,13 @@ docker run --env-file .env -p 8080:8080 messageweave:latest 不用 Docker 的本地构建:`cargo build --locked` 产出 `message-weave` 二进制。 如果服务有响应但什么都不做,请查看启动日志的告警:**缺少必需变量不会让进程崩溃**,它会 -退化为运行在内存态上的只读配置路由。见 §3。 +退化为运行在内存态上的只读配置路由。见 §4。 -## 3. 配置入口 +## 4. 配置入口 共三层,彼此不可替代。 -**启动期环境变量 —— 2 个必需,2 个可选。** +**启动期环境变量 —— 2 个必需,1 个可选。** | 变量 | 必需 | 默认值 | |---|---|---| @@ -67,7 +91,7 @@ docker run --env-file .env -p 8080:8080 messageweave:latest `GET /healthz` 无条件返回 200——它是存活探测,不是就绪探测。`GET /ready` 在配置、Redis 与 两条上游(JMAP session `GET`、Telegram `getMe`,各 3s)可达之前返回 503。判断是否可路由 流量时,请探测 `/ready` 而不是 `/healthz`;但 `/ready` 是较重的探针(最坏约 3s),需要 -保持轻量的监控请看网关聚合的 `/healthz`。 +保持轻量的监控在启用网关时请看网关聚合 `GET /healthz-worker`。 **远程联调面(可选,默认关闭)。** `/debug/*` 是一套可选的远程联调面:JMAP 与 Telegram 实时探针、当前业务配置,以及单条 @@ -77,13 +101,13 @@ Telegram 发送。它由双因子开关控制——进程必须带 `--debug` ** 设 `DEBUG_TOKEN`。若确实要为一次排障临时开启,请先配好 chat 白名单——白名单为空时,测试 发送不会受限到任何 chat。见 `docs/deployment.md` §2.1。 -## 4. 安全边界,一句话 +## 5. 安全边界,一句话 > 所有状态都在 Redis 里;进程只读两个环境变量,不写任何磁盘。 有一条推论与部署直接相关:SPA 管理凭据——也就是 bootstrap 的信任根——就是 `CONFIG_ENCRYPTION_KEY` 本身,即启动时提供的那个 32 字节 hex 值。它只做常数时间比较,绝不回显、记录或落盘。Redis ACL 密码(如有)只认证 Redis 连接本身,不是任何 HTTP 端点的认证凭据。 -## 5. 去哪读更多 +## 6. 去哪读更多 | 文档 | 回答什么 | |---|---| @@ -93,6 +117,7 @@ Telegram 发送。它由双因子开关控制——进程必须带 `--debug` ** | [`docs/opengaps.zh-CN.md`](docs/opengaps.zh-CN.md) | 缺口、阻塞项与下一阶段目标 | | [`docs/retired.zh-CN.md`](docs/retired.zh-CN.md) | 试过但没用的:废弃路线、未落地的设计与从未存在的名字 | | [`docs/charter.zh-CN.md`](docs/charter.zh-CN.md) | 项目章程:项目目标、锁定的技术选型、安全不变量、禁止事项与稳定 ID 注册表 | +| [`cloudflare-worker/README.zh-CN.md`](cloudflare-worker/README.zh-CN.md) | Cloudflare Worker 网关:路由白名单、超时与重试预算、有界故障转移、健康聚合、部署 | | [`AGENTS.md`](AGENTS.md) | 语言无关的工程规范:代码风格、配置与密钥、构建环境、测试门禁、文档治理 | 重要的事实都可追溯。两份文档不一致时,以 `docs/reference.md` 为准。 diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 2ced1fd..99e8494 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -1,120 +1,295 @@ -# MessageWeave Cloudflare Worker(ARCH-LB-WORKER) +# MessageWeave Cloudflare Worker gateway -统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。 -**透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。 +> [中文版本 / Chinese version → README.zh-CN.md](README.zh-CN.md) -> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 18 条白名单、 -> 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与 -> 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。 +This is the canonical English version. The Chinese version is +[README.zh-CN.md](README.zh-CN.md). -> 设计依据:`docs/design.md §10 / NFR-HA-MULTI-INSTANCE`、`docs/deployment.md §10`。 -> 安全基线与禁止事项:`docs/charter.md §3/§5`(稳定 ID 注册表 §8)。 +One public HTTPS entry point in front of one or more MessageWeave backends, with bounded +multi-origin failover (HA/LB sub-project). **Pass-through model**: the Worker does not parse +business payloads and forwards the request as-is to the backend https origins; only *timeout / +5xx* triggers a bounded failover to another instance. -## 目录 +> This component is a **safelist-restricted edge load balancer**: the route set is fixed (19 +> whitelisted paths), the backend origins are fixed at deploy time and https-only, and every +> unknown path gets a `404`. It only forwards requests to those fixed backends and fails over +> between them. It does not accept an arbitrary target host and provides no general-purpose +> traffic-relay or access-hiding capability. -- `wrangler.toml` — Wrangler 示例配置(name/main/compat/vars;后端 secret 走 `wrangler secret`) -- `src/index.js` — Worker 入口(路由分发、/healthz-worker 聚合、safelist 校验) -- `src/backends.js` — origin 解析 + https/凭据/query 校验(C-HTTPS-INBOUND) -- `src/lb.js` — 透传转发 + 有界故障转移(SAF-LB-PASSTHRU) -- `src/health.js` — 健康聚合探针(MOD-HEALTH-AGG / C-NO-DB) -- `test/*.test.js` — 单元/集成测试(零依赖,`node --test`) -- `package.json` — npm 元数据(`dev`/`deploy` 调 wrangler;`test` 纯 node) +> Design basis: `docs/design.md §11.3` (`NFR-HA-MULTI-INSTANCE`) and `docs/deployment.md §10`. +> Security baseline and prohibitions: `docs/charter.md §3`, `docs/charter.md §5`; stable-ID +> registry `docs/charter.md §8`. -## 零依赖设计 +## 1. What it is, and what it is not -运行时仅依赖 ES2022 标准 + 平台/Node 的 `fetch`/`Headers`/`Request`/`Response`/`URL`。 -`npm run test` 直接 `node --test test/`,**无需安装**任何生产依赖; -`wrangler` 仅在 `check`/`deploy`/`dev` 中作 devDependency(CI 可选装)。 +- **Forward only.** Method, headers (including auth headers) and body are forwarded as-is. The + Worker performs no authentication of its own and rewrites nothing. +- **Secretless.** No Telegram bot token, no JMAP password, no session secret. It forwards + whatever auth header it is given and checks none of them. All backends must instead share + the same set of `SAF-AUTH-*` secrets — `RECONCILE_TOKEN`, `TG_WEBHOOK_SECRET` and the + encrypted business config — because a callback can land on any instance and no instance can + prove which one was asked (`C-LB-SHARED-SECRETS`). Per-instance secrets mean a random 401. +- **No origin discovery.** Origins come from the deploy-time origin list only. +- **Zero runtime dependencies.** Pure ES2022 plus the platform `fetch`, `Headers`, `Request`, + `Response` and `URL`. `wrangler` is a devDependency, used only by `check`, `deploy` and + `dev`. +- **Zero state.** No Redis, no JMAP, no database (`C-NO-DB`, `C-REDIS-ONLY-STATE`). The only + mutable state is an in-isolate health probe cache. +- **No long-lived connections** (`C-NO-LONG-CONN`). Pure request-response. The request body is + read once as `arrayBuffer` and re-attached on every attempt, so streaming is never required. +- **Not a queue.** There is no retry beyond the bounded attempts below. When every origin + fails the gateway answers `503`, and Telegram / Stalwart redelivery is what makes up for it. -## 语义 +## 2. Directory layout -### 透传(A,SAF-LB-PASSTHRU) -- 原样转发 method / headers(含鉴权头)/ body;不鉴权改写。 -- 所有后端实例共享同一组 secret(`C-LB-SHARED-SECRETS`):同一 `TELEGRAM_WEBHOOK_SECRET`、 - `JMAP_PUSH_VERIFICATION_CODE`、`API_TOKEN`(`API_TOKEN` 在 `docs/deployment.md` §10 仅用于多实例互斥,非鉴权) - + 相同 App Password(各后端实例的 `JMAP` 可不同,`docs/deployment.md` §10.2)。 +| file | role | +|---|---| +| `wrangler.toml` | `name`, `main`, compatibility flags, `[vars]` | +| `package.json` | `check` / `test` / `deploy` / `dev` scripts only, no runtime dependencies | +| `src/index.js` | entry point: HTTPS redirect, route dispatch, safelist, health aggregate | +| `src/backends.js` | origin parsing and validation, route safelist | +| `src/lb.js` | pass-through forwarding and bounded failover | +| `src/health.js` | TTL-cached health aggregate probe | +| `test/*.test.js` | 38 tests in four files, all offline | -### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL) -- HTTP requests receive a permanent `308` redirect to the same HTTPS URL before route validation or forwarding. -- 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/enabled`(业务总开关,SPA 服务卡片读写)、`GET|PUT /api/config`、`GET|PUT /api/business-config`(读回 + 增量保存)、`POST /api/business-config/preflight`(保存前逐组件连通性预检)。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。 -- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/telegram/register-webhook`、`POST /api/push/disable`、`POST /reconcile`、`POST /worker`、`GET /ready`。 -- 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。 -- method 不符 → **405**。 +## 3. Pass-through semantics -### 有界故障转移(`docs/deployment.md` §10.4) -- 每次请求只试 `min(LB_MAX_ATTEMPTS, origins.length)` 次;默认 2(首次 + 1 次换实例)。 -- **仅** 超时(AbortError)或 5xx 触发换下一个实例;4xx / 2xx / 3xx 直接返回。 -- 起点按 `rng` 随机化(默认 `Math.random`),实现双活分摊;单 origin 配置时退化为确定性。 -- 全失败 → **503 All Backends Unavailable**(交由 Telegram / Stalwart 自动重投兜底,不丢消息)。 -- 健康缓存(`aggregateHealth` / `LB_HEALTH_TTL_MS`)仅服务于 `/healthz-worker` 聚合视图;转发目前**不做健康路由过滤**(`docs/deployment.md` §10.4「只向健康实例转发」为后续增强,当前靠故障转移兜底)。 -- **例外:`POST /reconcile` 与 `POST /worker` 走 per-route 覆盖。** 两者都是后端同步长任务: - 全局 10s 超时会把它们误判为失败并故障转移到第二实例,而第二实例对 `/reconcile` 必然立刻 - 返回 409(集群级锁 `lock:reconcile`,初租 300s 由心跳续期),对 `/worker` 只会重复排同一批 - 消息。所以这两条路由改为:超时分别取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000`,留足锁初租 + - 心跳余量)与 `LB_WORKER_TIMEOUT_MS`(默认 `300000`,等于后端单条事件下界),且 `maxAttempts=1` - 绝不故障转移。其余快路径保持全局默认。 +- Method, headers and body go out unchanged; the backend response status, body and headers are + returned as-is. +- The only header the gateway adds is best-effort observability: `x-lb-backend`, naming the + origin that answered. The write sits behind a `try/catch`. A cross-origin response that + carries no `access-control-allow-*` header comes back with an immutable header guard, and + `headers.set` throws on those. An observability write that throws must never mask a healthy + `2xx` as a `503`, so the header is dropped silently when it cannot be written. That exact + defect was the 2026-09-30 production incident. +- `GET` and `HEAD` never forward a body: a bodyless upstream response is returned as it is, + and no `Content-Length` is recomputed. +- Backend `3xx` redirects are followed by the gateway itself (`redirect: "follow"`), so a + redirecting origin never hands the browser a second hop. +- Logs and error strings carry method, path, origin and failure class only. Never headers, + body, auth secrets or App Password (`SAF-LOG-PURITY`). -### 健康聚合(MOD-HEALTH-AGG;C-NO-DB / C-REDIS-ONLY-STATE) -- `GET /healthz-worker`(由 Worker 自身承载;入站 `/healthz` 保留给源站自己的健康检查并透传,两者互不遮蔽): - 按 TTL 缓存(默认 30s)探测各后端 `/healthz`,返回 - `{status: ok|down, version, available, total, backends:[{origin, up, status}]}`; - 有 ≥1 后端 up → 200;全部 down → 503。 -- **不代理 Redis/JMAP**:Worker 不做数据库侧检查;后端端到端就绪(配置完整性 + Redis 可达 + JMAP session + Telegram getMe,对应 `ARCH-READY-BASELINE`)由 `/ready`(透传)承担。 +## 4. Route safelist -### 无长连接 / 无密钥日志(§3/§5 红线) -- 不使用 WebSocket/SSE/长轮询(`C-NO-LONG-CONN`):纯请求-响应转发,body 一次性 `arrayBuffer` 回灌。 -- 日志只打「方法 / 路径 / origin / 失败类别」;**绝不**打印 header / body / 鉴权 secret / App Password(`SAF-LOG-PURITY`)。 +Every inbound **HTTP** request gets a permanent **308** redirect to the same HTTPS URL before +route validation or forwarding (`C-HTTPS-INBOUND`). After that the request must hit one of 19 +paths and carry a method that path allows; every other combination is refused at the edge. -## 环境变量 +| path | methods | +|---|---| +| `/` | GET | +| `/assets/config.js` | GET | +| `/assets/styles.css` | GET | +| `/api/status` | GET | +| `/api/config` | GET PUT | +| `/api/business-config` | GET PUT | +| `/api/business-config/preflight` | POST | +| `/api/admin/session` | POST | +| `/api/admin/session/revoke` | POST | +| `/api/enabled` | GET PUT | +| `/webhook/tg` | POST | +| `/push/jmap` | POST | +| `/api/push/register` | POST | +| `/api/telegram/register-webhook` | POST | +| `/api/push/disable` | POST | +| `/reconcile` | POST | +| `/worker` | POST | +| `/healthz` | GET | +| `/ready` | GET | -| 变量 | 必填 | 说明 | -|---|---|---| -| `BACKEND_ORIGINS_JSON` | 是 | JSON 数组,形如 `["https://a.platform1.example","https://b.platform2.example"]`;必须全部 https、无内嵌凭据/query/fragment/路径。已写在 `wrangler.toml` 的 `[vars]`。 | -| `LB_REQUEST_TIMEOUT_MS` | 否 | 单 origin 请求超时;默认 `10000`。建议 > 最坏 cold start + 最长 JMAP 拉取。 | -| `LB_MAX_ATTEMPTS` | 否 | 每请求最多 origin 尝试次数;默认 `2`(= 1 次故障转移)。`POST /reconcile` 与 `POST /worker` 固定为 `1`。 | -| `LB_RECONCILE_TIMEOUT_MS` | 否 | 仅 `POST /reconcile` 的单 origin 超时覆盖;默认 `320000`(须大于后端锁初租 300s + 心跳余量)。 | -| `LB_WORKER_TIMEOUT_MS` | 否 | 仅 `POST /worker` 的单 origin 超时覆盖;默认 `300000`(= 后端单条事件下界 `SINGLE_EVENT_CEILING_FLOOR_MS`)。一次排空 batch 条(默认 10,硬上限 10),batch 拉大时需相应上调。 | -| `LB_HEALTH_TTL_MS` | 否 | 健康探测缓存 TTL;默认 `30000`。 | -| `LB_VERSION` | 否 | `/healthz-worker` 响应体里的 `version` 字段,用于确认线上是哪次部署在回答;取自 `wrangler.toml` 的 `[vars]`(当前 `2026.10.4`),改 LB 逻辑的提交同时 bump | +- Unknown path: `404`, body `route not forwarded: `. The gateway can therefore never be + turned into a jump host for arbitrary backend paths. +- Method mismatch on a known path: `405`, body `method not allowed for `, plus an + `Allow` header. +- Bootstrap (`POST /api/bootstrap`) and the remote diagnostics face (`/debug/*`) are + deliberately absent, so both stay origin-only. +- `/healthz-worker` is not in the safelist at all: the gateway serves it itself, on `GET` + only. -> 默认写在 `wrangler.toml` 的 `[vars]`(**明文例外**,因为当前值只是公开可达的 https origin, -> 不是凭据)。一旦要放内网地址、带内嵌凭据的 URL、或不想公开的 staging 主机名, -> 立刻改用 `npx wrangler secret put BACKEND_ORIGINS_JSON` 并把该行从 `[vars]` 删掉。 +## 5. Timeout and retry budget -## 本地 / CI 验证 +| variable | required | default | applies to | +|---|---|---|---| +| `BACKEND_ORIGINS_JSON` | yes | — | all routes | +| `LB_REQUEST_TIMEOUT_MS` | no | `10000` | every route except the two below, and the §7.2 probe | +| `LB_MAX_ATTEMPTS` | no | `2` | attempts per request | +| `LB_RECONCILE_TIMEOUT_MS` | no | `320000` | `POST /reconcile` only | +| `LB_WORKER_TIMEOUT_MS` | no | `300000` | `POST /worker` only | +| `LB_HEALTH_TTL_MS` | no | `30000` | `/healthz-worker` probe cache only | +| `LB_VERSION` | no | `unknown` | `/healthz-worker` `version` field | + +Integer env values are parsed with `Math.floor`. A non-finite or non-positive value is ignored +and the default applies, so a typo cannot switch the budget off. + +`/reconcile` and `/worker` are **single-attempt routes** with per-route timeout overrides. +Both are long synchronous backend jobs: at the 10 s global timeout the gateway would declare +them failed and fail over to a second instance, but that instance answers `/reconcile` with +`409` immediately (the cluster-wide `lock:reconcile` starts with a 300 s lease, renewed by +heartbeat) and a second `/worker` would merely drain the same batch twice. They therefore get +`LB_RECONCILE_TIMEOUT_MS` (default 320 s, headroom over the 300 s lease plus heartbeats) and +`LB_WORKER_TIMEOUT_MS` (default 300 s, equal to the backend per-event floor +`SINGLE_EVENT_CEILING_FLOOR_MS`), both with `max_attempts = 1`. + +`LB_WORKER_TIMEOUT_MS` must grow if the `/worker` batch grows: the backend batch default and +its hard cap are both 10. + +## 6. Bounded failover + +- Each request tries `max(1, min(LB_MAX_ATTEMPTS, origins.length))` origins. The default of + 2 is one first attempt plus one failover. +- Only a timeout or a `5xx` response moves on to the next origin. `2xx`, `3xx` and `4xx` are + answers and are returned immediately. +- A timeout or transport failure counts as retryable when the error name is `AbortError`, or + when the message, or the message of a `cause` further down the chain, matches one of: + `fetch failed`, `network error`, `socket hang up`, `ECONNREFUSED`, `ECONNRESET`, + `EAI_AGAIN`, `ENOTFOUND`, `ETIMEDOUT`, `EHOSTUNREACH`, `ENETUNREACH`. Anything else fails + fast. +- With more than one origin and more than one attempt, the starting origin is chosen with + `rng` (defaulting to `Math.random`) so load spreads over the healthy instances. A single + origin degrades to a fixed order. +- Every origin fails: `503` with status text `All Backends Unavailable` and body + `all backends failed: `. Telegram and Stalwart then redeliver, which is why + nothing is lost. + +The health cache feeds only the `/healthz-worker` aggregate view. Forwarding does **not** +filter by health; today, failover is the safety net. + +## 7. Health endpoints + +The two endpoints deliberately do not overlap, so neither can hide the other. + +### 7.1 GET /healthz — passthrough + +Forwarded like any other safelisted route. The response is the backend's own liveness +envelope, which is exactly why it can read healthy while a different origin is down. + +### 7.2 GET /healthz-worker — gateway aggregate + +Answered by the gateway itself. It never touches the forwarded route set. + +- Probes each origin's `/healthz` with the same request timeout as forwarding + (`LB_REQUEST_TIMEOUT_MS`, default 10 s). Redirects are followed, so an origin that answers + with a `3xx` still counts as up. +- `up` is true when the origin answered with a status below 500. A probe failure records + `up: false` with `status: null`. +- Results are cached per origin for `LB_HEALTH_TTL_MS` (default 30 s) in an in-isolate map, + to keep probe traffic bounded. A cache miss probes and stores; a fresh entry is served + without another probe. +- Response body: `{status, version, available, total, backends: [{origin, up, status}]}`. + - at least one origin up: HTTP `200`, `status: "ok"` + - every origin down: HTTP `503`, `status: "down"` + - no origins configured: HTTP `200`, `status: "no-backends"` + - `version` is `LB_VERSION`, defaulting to the literal string `unknown` +- Any method other than `GET`: `405` with `Allow: GET`. +- The gateway does not proxy Redis or JMAP readiness. End-to-end backend readiness + (`ARCH-READY-BASELINE`: config completeness, Redis reachable, JMAP session, Telegram + `getMe`) stays on the backend's `/ready`, which is passed through. + +## 8. Callback registration + +The SPA registers callback URLs against **one** origin (`C-LB-SINGLE-REG-URL`). Origin only, +no path: + +- Gateway enabled: the Worker URL, for example `https://lb.example`. +- Gateway disabled: a backend origin, for example `https://a.example`. + +Whichever origin you register is where `/webhook/tg` and `/push/jmap` land. All three +registration endpoints are safelisted and pass through the gateway, so registration works +through either front door. Registration is idempotent, and registering a new address retires +the previous subscription — that is how you switch fronts. + +## 9. Configuration + +- Defaults live in `wrangler.toml` `[vars]` as a **deliberate plaintext exception**: the + current value is only publicly reachable https origins, which is not a credential. The + moment you need an internal address, a URL with embedded credentials, or a hostname that + should not be public, move it to `npx wrangler secret put BACKEND_ORIGINS_JSON` and delete + the line from `[vars]`. Git history is irreversible, so deleting it does not un-publish it. +- `BACKEND_ORIGINS_JSON` is a JSON **array of plain strings**. Object shapes such as + `{"url": "..."}` are rejected: `parseBackendOrigins` throws, and because the config is + re-parsed on **every** request the failure shows up as `503` on every request rather than + at boot. The body is `misconfigured backends`. An empty list throws the same way, before + anything is forwarded. +- Each entry must be an https **origin** only: no http, no path, no query, no fragment, no + embedded credentials (`C-HTTPS-INBOUND`). +- `wrangler.toml` also pins `name = "messageweave-lb"`, `main = "src/index.js"`, + `compatibility_date = "2025-01-01"` and + `compatibility_flags = ["nodejs_compat_v2"]`. `nodejs_compat_v2` is what lets the test + files import `node:test` and `node:assert/strict`. The runtime code imports no + node module. +- Comments in `wrangler.toml` must stay single-line `#` comments. TOML has no block comment, + so a JSDoc `/** */` block makes wrangler fail to parse the file at all. + +## 10. Local and CI verification ```bash cd cloudflare-worker -# 单元/集成测试(零依赖): -npm run test -# 等价:node --test test/ - -# 语法检查 + wrangler dry-run(需 npx wrangler,会拉 devDependency): -npm run check -# 等价:node --check src/*.js && node --check test/*.test.js && wrangler deploy --dry-run +npm test # 38 tests, four files, fully offline +npm run check # node --check on sources and tests, then wrangler deploy --dry-run ``` -## 部署示例 +`npm test` is `node --test`, which picks up `test/*.test.js`: `test/index.test.js` 15, +`test/lb.test.js` 12, `test/backends.test.js` 6, `test/health.test.js` 5. There is no install +step for the tests — the Worker has no runtime dependencies, and the test files import only +`node:test` and `node:assert/strict`. + +Two traps: + +- `node --test test/` (a directory) exits 0 after running **zero** tests on some Node + versions. Always pass the file glob. +- A default CI install step of `bun install` ignores the npm lockfile: it re-resolves and can + move wrangler across major versions. Add an explicit `npm ci` before the test step if you + need the pinned wrangler. + +## 11. Deploy ```bash cd cloudflare-worker -# 安装 dev dep(仅 wrangler): npm install --no-audit --no-fund -# BACKEND_ORIGINS_JSON 已写在 wrangler.toml 的 [vars],直接部署: -npx wrangler deploy -# 可选调参(默认值已够用;要覆盖就用 dashboard 的 Add Variable): -# LB_REQUEST_TIMEOUT_MS / LB_MAX_ATTEMPTS / LB_HEALTH_TTL_MS -# LB_RECONCILE_TIMEOUT_MS / LB_WORKER_TIMEOUT_MS -# 仅当 [vars] 里要放非公开值时才改用 secret 注入: -npx wrangler secret put BACKEND_ORIGINS_JSON -# 部署后确认: -curl https://.workers.dev/healthz +npm run deploy +curl https://.workers.dev/healthz-worker ``` -健康探针: +`npm run check` includes `wrangler deploy --dry-run --outdir=.build-check`, which bundles the +four sources and reports the artifact size before anything is published. The gateway is +deployed from push, so a `curl` of `/healthz-worker` and a compare of its `version` is the +acceptance check. Bump `LB_VERSION` in the same commit as any LB logic change, so you can tell +which build answered. -```bash -curl https://lb.messageweave.example/healthz -# => {"status":"ok","available":2,"total":2,"backends":[{"origin":"https://a.example","up":true,"status":200},...]} -``` +Two production incidents worth keeping in mind: + +- **2026-09-30**: a build wrote `x-lb-backend` onto proxied responses. Cross-origin responses + without `access-control-allow-*` headers have an immutable header guard, so `headers.set` + threw and the surrounding handler logged a healthy backend as unavailable and burned its + retries — the gateway returned `503` while `GET /` actually returned `200`. + `/healthz-worker` did not go through that code path, which is exactly what hid the outage. + Fixed by making the header write best-effort. +- **Object-shaped origin lists fail silently at parse time.** `parseBackendOrigins` throws and + you get `503 misconfigured backends` on every request. Keep `weight` and any other + per-origin tuning out of the list: nothing reads them. + +## 12. Design invariants + +- `SAF-LB-PASSTHRU` — no auth, header or body rewriting on the path +- `C-HTTPS-INBOUND` — https-only origins, embedded credentials and paths rejected, HTTP gets a + 308 +- `C-LB-SINGLE-REG-URL` — one callback origin, unknown paths 404 +- `C-LB-SHARED-SECRETS` — every backend carries identical business credentials +- `MOD-HEALTH-AGG` — TTL-cached aggregate probe owned by the gateway +- `C-NO-DB`, `C-REDIS-ONLY-STATE` — no database or Redis access from the gateway +- `C-NO-LONG-CONN` — request-response only +- `ARCH-READY-BASELINE` — end-to-end readiness stays on the backend's `/ready` +- `SAF-LOG-PURITY` — method, path, origin and failure class only +- `ARCH-LB-WORKER` — the gateway is the only edge component +- `NFR-HA-MULTI-INSTANCE` — multi-origin failover is how HA is realised + +## References + +- `docs/deployment.md §10` — gateway deployment, configuration and rollout +- `docs/reference.md §4` — gateway versus backend route matrix +- `docs/reference.md §9.2` — backend configuration and secrets +- `docs/reference.md §9.4` — health and release checks +- `docs/design.md §11.3` — HA and multi-instance design +- `docs/charter.md §3`, `docs/charter.md §5`, `docs/charter.md §8` — security baseline, + prohibitions, stable-ID registry diff --git a/cloudflare-worker/README.zh-CN.md b/cloudflare-worker/README.zh-CN.md new file mode 100644 index 0000000..64d746c --- /dev/null +++ b/cloudflare-worker/README.zh-CN.md @@ -0,0 +1,194 @@ +# MessageWeave Cloudflare Worker 网关 + +> [English version / 英文版 → README.md](README.md) + +面向一个或多个 MessageWeave 后端的统一 HTTPS 入口,带**有界多 origin 故障转移**(HA/LB 子项目)。**透传模型**:Worker 不解析业务载荷,把请求原样转发给后端 https origin;只有*超时 / 5xx* 才触发有界的故障转移到另一个实例。 + +> 本组件是**基于白名单限制的边缘负载均衡器**:路由集合固定(19 个白名单路径)、后端 origin 在部署时固定且仅允许 https、任何未知路径一律 `404`。它只把这些固定后端之间的请求做转发与故障转移,不接受任意目标主机,也不提供任何通用的流量中转或隐身能力。 + +> 设计依据:`docs/design.md §11.3`(`NFR-HA-MULTI-INSTANCE`)与 `docs/deployment.md §10`。 +> 安全基线与禁令:`docs/charter.md §3`、`docs/charter.md §5`;稳定 ID 注册表 `docs/charter.md §8`。 + +## 1. 它是什么,不是什么 + +- **只转发。** method、headers(含认证头)、body 一律原样转发。Worker 自身不做任何认证,也不改写任何内容。 +- **无 secret。** 没有 Telegram bot token,没有 JMAP 密码,没有 session secret。它收到什么认证头就转发什么,自己一个都不校验。真正共享的是后端之间必须一致的同一组 `SAF-AUTH-*` secret——`RECONCILE_TOKEN`、`TG_WEBHOOK_SECRET` 与加密后的业务配置——因为回调可能落在任何一个实例上,而任何实例都证明不了自己是被点名的那一个(`C-LB-SHARED-SECRETS`)。凭据不一致的表现是随机 401,而不是路由错误。 +- **不做 origin 发现。** origin 只来自部署时的 origin 列表。 +- **零运行时依赖。** 纯 ES2022 加平台提供的 `fetch`、`Headers`、`Request`、`Response`、`URL`。`wrangler` 是 devDependency,只用于 `check`、`deploy`、`dev`。 +- **零状态。** 不连 Redis、不连 JMAP、不连数据库(`C-NO-DB`、`C-REDIS-ONLY-STATE`)。唯一的可变状态是 isolate 内的健康探测缓存。 +- **无长连接**(`C-NO-LONG-CONN`)。纯请求-响应。请求体只读一次为 `arrayBuffer`,每次尝试重新挂上去,因此不需要流式。 +- **不是队列。** 除了下面那几轮有界尝试,不做任何重试。所有 origin 都失败时网关返回 `503`,靠 Telegram / Stalwart 的自身重投兜底。 + +## 2. 目录结构 + +| 文件 | 职责 | +|---|---| +| `wrangler.toml` | `name`、`main`、compatibility flags、`[vars]` | +| `package.json` | 只有 `check` / `test` / `deploy` / `dev` 脚本,无运行时依赖 | +| `src/index.js` | 入口:HTTPS 重定向、路由分发、白名单、健康聚合 | +| `src/backends.js` | origin 解析与校验、路由白名单 | +| `src/lb.js` | 透传转发与有界故障转移 | +| `src/health.js` | 带 TTL 的健康聚合探测 | +| `test/*.test.js` | 四个文件共 38 条测试,全部离线 | + +## 3. 透传语义 + +- method、headers、body 原样发出;后端响应的 status、body、headers 原样返回。 +- 网关唯一新增的 header 是尽力而为的观测头:`x-lb-backend`,标记应答的 origin。写入放在 `try/catch` 里。跨域且不带 `access-control-allow-*` 的响应,其 header guard 是 immutable,`headers.set` 会抛异常。一次会抛异常的观测写入绝不能把健康的 `2xx` 掩盖成 `503`,所以写不进去时静默放弃该 header。这个缺陷就是 2026-09-30 的生产事故根因。 +- `GET` 和 `HEAD` 不带 body 转发:上游没有 body 的响应直接原样返回,不重算 `Content-Length`。 +- 后端的 `3xx` 由网关自己跟随(`redirect: "follow"`),跳转型的 origin 不会把第二跳甩给浏览器。 +- 日志与错误串只包含 method、path、origin、失败类别,绝不含 headers、body、认证 secret 或 App Password(`SAF-LOG-PURITY`)。 + +## 4. 路由白名单 + +所有入站 **HTTP** 请求先被永久 **308** 重定向到同一个 HTTPS URL,之后才做路由校验与转发(`C-HTTPS-INBOUND`)。此后请求必须命中 19 个路径之一且 method 匹配,其余组合一律在边缘拒绝。 + +| 路径 | 允许的 method | +|---|---| +| `/` | GET | +| `/assets/config.js` | GET | +| `/assets/styles.css` | GET | +| `/api/status` | GET | +| `/api/config` | GET PUT | +| `/api/business-config` | GET PUT | +| `/api/business-config/preflight` | POST | +| `/api/admin/session` | POST | +| `/api/admin/session/revoke` | POST | +| `/api/enabled` | GET PUT | +| `/webhook/tg` | POST | +| `/push/jmap` | POST | +| `/api/push/register` | POST | +| `/api/telegram/register-webhook` | POST | +| `/api/push/disable` | POST | +| `/reconcile` | POST | +| `/worker` | POST | +| `/healthz` | GET | +| `/ready` | GET | + +- 未知路径:`404`,body 为 `route not forwarded: `。因此网关永远不可能被当作跳板去访问后端的任意路径。 +- 路径已知但 method 不匹配:`405`,body 为 `method not allowed for `,并附带 `Allow` header。 +- 引导接口(`POST /api/bootstrap`)与远程诊断面(`/debug/*`)刻意不在白名单内,两者都只保留在 origin 直连。 +- `/healthz-worker` 完全不在白名单里:它由网关自己应答,且只接受 `GET`。 + +## 5. 超时与重试预算 + +| 变量 | 必填 | 默认值 | 适用范围 | +|---|---|---|---| +| `BACKEND_ORIGINS_JSON` | 是 | — | 所有路由 | +| `LB_REQUEST_TIMEOUT_MS` | 否 | `10000` | 除下面两条外的所有路由,以及 §7.2 的探测 | +| `LB_MAX_ATTEMPTS` | 否 | `2` | 每请求的尝试次数 | +| `LB_RECONCILE_TIMEOUT_MS` | 否 | `320000` | 仅 `POST /reconcile` | +| `LB_WORKER_TIMEOUT_MS` | 否 | `300000` | 仅 `POST /worker` | +| `LB_HEALTH_TTL_MS` | 否 | `30000` | 仅 `/healthz-worker` 探测缓存 | +| `LB_VERSION` | 否 | `unknown` | `/healthz-worker` 的 `version` 字段 | + +整数型 env 值用 `Math.floor` 解析。非有限数或非正值会被忽略并回落到默认值,所以手滑不会把预算关掉。 + +`/reconcile` 与 `/worker` 是**单次尝试路由**,各自有独立的超时覆盖。两者都是后端耗时的同步长任务:按全局 10 s 超时,网关会判定它们失败并故障转移到第二个实例,但第二个实例对 `/reconcile` 会立刻返回 `409`(集群级 `lock:reconcile` 初始租约 300 s,靠心跳续租),而第二个 `/worker` 只会把同一批消息再排一遍。所以这两条路由拿到 `LB_RECONCILE_TIMEOUT_MS`(默认 320 s,比 300 s 租约加心跳多留余量)与 `LB_WORKER_TIMEOUT_MS`(默认 300 s,等于后端单条事件地板 `SINGLE_EVENT_CEILING_FLOOR_MS`),并且都是 `max_attempts = 1`。 + +如果 `/worker` 的批大小上调,`LB_WORKER_TIMEOUT_MS` 必须跟着加:后端批量的默认值与硬上限都是 10。 + +## 6. 有界故障转移 + +- 每个请求尝试 `max(1, min(LB_MAX_ATTEMPTS, origins.length))` 个 origin。默认 2 即首次加一次故障转移。 +- 只有超时或 `5xx` 才会换下一个 origin。`2xx`、`3xx`、`4xx` 都是应答,立即原样返回。 +- 超时与网络层失败被判为可重试的条件:错误 name 是 `AbortError`,或消息、或 `cause` 链上更深的错误消息命中 `fetch failed`、`network error`、`socket hang up`、`ECONNREFUSED`、`ECONNRESET`、`EAI_AGAIN`、`ENOTFOUND`、`ETIMEDOUT`、`EHOSTUNREACH`、`ENETUNREACH`。其余一律 fail-fast。 +- 当 origin 大于 1 且尝试次数大于 1 时,起点 origin 由 `rng` 决定(默认 `Math.random`),把负载摊到健康实例上;单个 origin 退化为固定顺序。 +- 所有 origin 都失败:`503`,status text 为 `All Backends Unavailable`,body 为 `all backends failed: `。随后由 Telegram 与 Stalwart 重投,消息因此不丢。 + +健康缓存只服务于 `/healthz-worker` 的聚合视图。转发路径**不**按健康过滤;现阶段故障转移就是安全网。 + +## 7. 健康接口 + +两个接口刻意不重叠,谁都不能掩盖谁。 + +### 7.1 GET /healthz — 透传 + +与其他白名单路由一样被转发。响应是后端自己的 liveness envelope,这也正是它在另一个 origin 已挂时依然显示健康的原因。 + +### 7.2 GET /healthz-worker — 网关聚合 + +由网关自己应答,完全不接触被转发的路由集合。 + +- 用与转发同源的请求超时(`LB_REQUEST_TIMEOUT_MS`,默认 10 s)探测每个 origin 的 `/healthz`。重定向会被跟随,所以返回 `3xx` 的 origin 同样计为 up。 +- origin 应答状态码低于 500 即为 `up`。探测失败记 `up: false` 且 `status: null`。 +- 结果按 origin 缓存 `LB_HEALTH_TTL_MS`(默认 30 s)在 isolate 内的 map 中,用来控制探测流量。未命中就探测并写入;未过期的缓存直接返回,不再探测。 +- 响应体:`{status, version, available, total, backends: [{origin, up, status}]}`。 + - 至少一个 origin 在线:HTTP `200`,`status: "ok"` + - 全部 origin 离线:HTTP `503`,`status: "down"` + - 未配置 origin:HTTP `200`,`status: "no-backends"` + - `version` 即 `LB_VERSION`,未配置时为字面量 `unknown` +- 任何非 `GET` 的 method:`405`,并带 `Allow: GET`。 +- 网关不代理 Redis 或 JMAP 的 readiness。端到端后端就绪判定(`ARCH-READY-BASELINE`:配置完整、Redis 可达、JMAP session、Telegram `getMe`)留在后端的 `/ready` 上,`/ready` 本身被透传。 + +## 8. 回调注册 + +SPA 的回调 URL 只注册**一个** origin(`C-LB-SINGLE-REG-URL`)。只填 origin,不带路径: + +- 启用网关:填 Worker URL,例如 `https://lb.example`。 +- 不启用网关:填后端 origin,例如 `https://a.example`。 + +你注册的是哪个 origin,`/webhook/tg` 与 `/push/jmap` 就落在哪。三个注册接口都在白名单内、都会经过网关透传,所以两种前门都能注册。注册是幂等的,且注册新地址会注销旧订阅——换前门就靠这一步。 + +## 9. 配置 + +- 默认值写在 `wrangler.toml` 的 `[vars]` 里,这是一个**刻意的明文例外**:当前取值只是公开可达的 https origin,不是凭据。一旦出现内网地址、带内嵌凭据的 URL、或不想公开的主机名,立刻改用 `npx wrangler secret put BACKEND_ORIGINS_JSON` 并删掉 `[vars]` 里那一行。git 历史不可逆,删掉并不等于没进过库。 +- `BACKEND_ORIGINS_JSON` 是**纯字符串数组**的 JSON。`{"url": "..."}` 这类对象形式会被拒绝:`parseBackendOrigins` 抛异常,而配置是**每个请求**重新解析的,所以表现是**每个请求都 503**,而不是启动即失败。body 为 `misconfigured backends`;空列表同样走这个异常路径,在转发任何请求之前就被拦下。 +- 每一项必须是 https **origin**:不允许 http、不允许 path、不允许 query、不允许 fragment、不允许内嵌凭据(`C-HTTPS-INBOUND`)。 +- `wrangler.toml` 同时固定 `name = "messageweave-lb"`、`main = "src/index.js"`、`compatibility_date = "2025-01-01"` 与 `compatibility_flags = ["nodejs_compat_v2"]`。`nodejs_compat_v2` 的作用是让测试文件能 import `node:test` 与 `node:assert/strict`;运行时代码不引入任何 node 模块。 +- `wrangler.toml` 的注释必须保持单行 `#` 注释。TOML 没有块注释,用 JSDoc 的 `/** */` 块会让 wrangler 直接解析不了整个配置文件。 + +## 10. 本地与 CI 验证 + +```bash +cd cloudflare-worker + +npm test # 四个文件共 38 条测试,全部离线 +npm run check # 对源码与测试跑 node --check,再 wrangler deploy --dry-run +``` + +`npm test` 即 `node --test`,会自动收集 `test/*.test.js`:`test/index.test.js` 15、`test/lb.test.js` 12、`test/backends.test.js` 6、`test/health.test.js` 5。跑测试不需要安装依赖——Worker 没有运行时依赖,测试文件只引入 `node:test` 与 `node:assert/strict`。 + +两个坑: + +- `node --test test/`(传目录)在某些 Node 版本上会跑完**零条**测试然后退出 0。必须传文件 glob。 +- 如果 CI 默认安装步骤是 `bun install`,它会忽略 npm lockfile:自行重新解析,可能把 wrangler 跨大版本移动。需要锁住 wrangler 时,在测试步骤前显式加一步 `npm ci`。 + +## 11. 部署 + +```bash +cd cloudflare-worker +npm install --no-audit --no-fund +npm run deploy +curl https://.workers.dev/healthz-worker +``` + +`npm run check` 内含 `wrangler deploy --dry-run --outdir=.build-check`,会在发布前把四个源文件打包并报告产物体积。网关由 push 触发部署,所以对 `/healthz-worker` 发一次 `curl` 并对比其 `version` 就是验收手段。改 LB 逻辑的提交要同时 bump `LB_VERSION`,这样才能判断线上是哪一次构建在应答。 + +两起值得记住的生产事故: + +- **2026-09-30**:某次构建把 `x-lb-backend` 写进了代理响应。跨域且不带 `access-control-allow-*` 的响应 header guard 是 immutable,`headers.set` 抛异常,外层 catch 于是把一个健康的后端记成不可用并耗尽重试——网关返回 `503`,而 `GET /` 实际返回的是 `200`。`/healthz-worker` 不走那段代码,这正是事故被掩盖的原因。已改为把 header 写入做成尽力而为。 +- **对象形式的 origin 列表在解析期静默失败。** `parseBackendOrigins` 抛异常后每个请求都是 `503 misconfigured backends`。`weight` 之类的 per-origin 调参不要写进列表,没有任何地方读它们。 + +## 12. 设计不变量 + +- `SAF-LB-PASSTHRU` — 透传路径不改写认证、header 或 body +- `C-HTTPS-INBOUND` — origin 仅允许 https,拒绝内嵌凭据与路径,HTTP 走 308 +- `C-LB-SINGLE-REG-URL` — 只注册一个回调 origin,未知路径 404 +- `C-LB-SHARED-SECRETS` — 所有后端携带同一组业务凭据 +- `MOD-HEALTH-AGG` — 由网关持有的带 TTL 健康聚合探测 +- `C-NO-DB`、`C-REDIS-ONLY-STATE` — 网关不访问数据库与 Redis +- `C-NO-LONG-CONN` — 仅请求-响应 +- `ARCH-READY-BASELINE` — 端到端就绪判定留在后端的 `/ready` +- `SAF-LOG-PURITY` — 仅 method、path、origin、失败类别 +- `ARCH-LB-WORKER` — 网关是唯一边缘组件 +- `NFR-HA-MULTI-INSTANCE` — 多 origin 故障转移即 HA 的实现方式 + +## References + +- `docs/deployment.md §10` — 网关部署、配置与上线 +- `docs/reference.md §4` — 网关与后端的路由矩阵 +- `docs/reference.md §9.2` — 后端配置与 secret +- `docs/reference.md §9.4` — 健康与发布检查 +- `docs/design.md §11.3` — HA 与多实例设计 +- `docs/charter.md §3`、`docs/charter.md §5`、`docs/charter.md §8` — 安全基线、禁令、稳定 ID 注册表 diff --git a/docs/charter.md b/docs/charter.md index fec430c..baa8162 100644 --- a/docs/charter.md +++ b/docs/charter.md @@ -178,7 +178,7 @@ Answer only one question per document; **Tables, lists, or values ​​may not | `docs/retired.md` | Records and alternative links to abandoned or renamed plans | Historical decisions | | `docs/charter.md` | This document: project constraints, technology selection, implementation stage, security invariants, stable ID registry | Project constraints | | `web/` (no documentation, 4 files) | Manage SPA sources: static configuration pages and front-end logic, covered by `web/config.test.mjs` | Front-end behavior (authoritative facts documented in `docs/design.md` and `docs/reference.md`) | -| `cloudflare-worker/README.md` | Configuration and semantics of the gateway itself | Gateway | +| `cloudflare-worker/README.md` / `cloudflare-worker/README.zh-CN.md` | Configuration and semantics of the gateway itself | Gateway | **Cross-document citation rules**: diff --git a/docs/charter.zh-CN.md b/docs/charter.zh-CN.md index 0e064af..e30110a 100644 --- a/docs/charter.zh-CN.md +++ b/docs/charter.zh-CN.md @@ -178,7 +178,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查 | `docs/retired.md` | 已废弃或已改名方案的记录与替代指向 | 历史决策 | | `docs/charter.md` | 本文件:项目约束、技术选型、实现阶段、安全不变量、稳定 ID 注册表 | 项目约束 | | `web/`(无文档,4 个文件) | 管理 SPA 源:静态配置页与前端逻辑,由 `web/config.test.mjs` 覆盖 | 前端行为(权威事实记在 `docs/design.md` 与 `docs/reference.md`) | -| `cloudflare-worker/README.md` | 网关自身的配置与语义 | 网关 | +| `cloudflare-worker/README.md` / `cloudflare-worker/README.zh-CN.md` | 网关自身的配置与语义 | 网关 | **跨文档引用规则**: diff --git a/scripts/docs_check/audit_anchor_semantics.py b/scripts/docs_check/audit_anchor_semantics.py index ad1ee10..4e9c4d3 100755 --- a/scripts/docs_check/audit_anchor_semantics.py +++ b/scripts/docs_check/audit_anchor_semantics.py @@ -49,7 +49,8 @@ DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', 'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md', 'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md', 'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md', - 'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md'] + 'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md', + 'cloudflare-worker/README.zh-CN.md'] FILE = r'((?:[A-Za-z0-9_.-]+/)?[A-Za-z0-9_-]+)' EXT = r'\.(rs|js):(\d+)' diff --git a/scripts/docs_check/audit_anchors.py b/scripts/docs_check/audit_anchors.py index 9db1946..9bb566c 100755 --- a/scripts/docs_check/audit_anchors.py +++ b/scripts/docs_check/audit_anchors.py @@ -45,7 +45,8 @@ DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', 'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md', 'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md', 'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md', - 'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md'] + 'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md', + 'cloudflare-worker/README.zh-CN.md'] # `Ident` or `Module::Ident` inside backticks, optional () after. IDENT = r'[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?' diff --git a/scripts/docs_check/check_sec_refs.py b/scripts/docs_check/check_sec_refs.py index e212ac9..1e8b4d1 100755 --- a/scripts/docs_check/check_sec_refs.py +++ b/scripts/docs_check/check_sec_refs.py @@ -17,7 +17,8 @@ DOCS = ["AGENTS.md", "README.md", "README.zh-CN.md", "docs/design.md", "docs/design.zh-CN.md", "docs/deployment.md", "docs/deployment.zh-CN.md", "docs/reference.md", "docs/reference.zh-CN.md", "docs/retired.md", "docs/retired.zh-CN.md", "docs/opengaps.md", "docs/opengaps.zh-CN.md", - "docs/charter.md", "docs/charter.zh-CN.md", "cloudflare-worker/README.md"] + "docs/charter.md", "docs/charter.zh-CN.md", "cloudflare-worker/README.md", + "cloudflare-worker/README.zh-CN.md"] HEADING = re.compile(r"^(#{2,4})\s+(\d+(?:\.\d+)?\.?)\s") # §2.3, §5.7, §3 (bare), also `§2.3/§3.4` chains handled by finditer @@ -42,12 +43,23 @@ def headings(path): def main(): names = {os.path.basename(p): p for p in DOCS} - heads = {os.path.basename(p): headings(os.path.join(ROOT, p)) for p in DOCS} + # `README.md` exists both at the repo root and under `cloudflare-worker/`, so a + # basename-keyed map silently drops one of them. Keep every file's own heading + # set and let the first occurrence win on a basename collision. + own_heads = {p: headings(os.path.join(ROOT, p)) for p in DOCS} + heads = {} + for p in DOCS: + heads.setdefault(os.path.basename(p), own_heads[p]) errors = [] checked = 0 for rel in DOCS: base = os.path.basename(rel) + # A basename held by more than one file (`README.md` lives both at the + # repo root and under `cloudflare-worker/`) must resolve against the + # document being scanned when it names that document itself. + heading_set = (lambda t: own_heads[rel] if t == base + else heads.get(t, set())) full = os.path.join(ROOT, rel) with open(full, encoding="utf-8") as fh: for lineno, line in enumerate(fh, 1): @@ -69,7 +81,7 @@ def main(): target = m.group(1) override_pos = m.end() checked += 1 - if m.group(2) not in heads.get(target, set()): + if m.group(2) not in heading_set(target): errors.append( f"{rel}:{lineno}: §{m.group(2)} does not exist " f"in {target}") @@ -93,7 +105,7 @@ def main(): re.search(r"([A-Za-z0-9_.-]+\.md)[`\s]*$", prefix).group(1)) checked += 1 - if num not in heads.get(target, set()): + if num not in heading_set(target): errors.append( f"{rel}:{lineno}: §{num} does not exist in {target}") diff --git a/scripts/docs_check/check_tables.py b/scripts/docs_check/check_tables.py index f9a8b49..d4a1fb3 100755 --- a/scripts/docs_check/check_tables.py +++ b/scripts/docs_check/check_tables.py @@ -6,7 +6,8 @@ DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', 'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md', 'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md', 'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md', - 'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md'] + 'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md', + 'cloudflare-worker/README.zh-CN.md'] CODE = re.compile(r'`[^`\n]*`') SEP = re.compile(r'^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$') diff --git a/scripts/docs_check/validate_docs.py b/scripts/docs_check/validate_docs.py index 825b2ec..5f87ed8 100755 --- a/scripts/docs_check/validate_docs.py +++ b/scripts/docs_check/validate_docs.py @@ -5,7 +5,8 @@ DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', 'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md', 'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md', 'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md', - 'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md'] + 'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md', + 'cloudflare-worker/README.zh-CN.md'] errors = [] def slug(t):