feat(status): derive `backend` from the request Host header, not a startup env

The previous round (88f0923) threaded a `backend_id` from `BACKEND_ID`/`HOSTNAME`
env vars into `AppState`. That required the operator to manually align each
instance's `BACKEND_ID` with the Cloudflare Worker's `BACKEND_ORIGINS_JSON`, which
is exactly the kind of manual cross-config sync that drifts.

The request's own `Host` header already carries the answer: the Worker load
balancer (`ARCH-LB-WORKER`) preserves the inbound `Host` while rewriting the target
to a backend origin (`joinUrl` builds `origin + path + query`), so `Host` is the
public address the LB routed this instance as. `backend_label_from_headers` reads
it per-request in `setup_status`, trims the default https/http port
(`example.com:443` -> `example.com`) so the label mirrors a bare origin, and returns
`None` when the request carries no usable `Host`.

This removes the startup env entirely — `backend_id_from_env`, `AppState::backend_id`,
all five router-builder `backend_id` parameters, the `.env.example` `BACKEND_ID`
entry, and the `BACKEND_ID` env documentation are all gone. Handlers still never
touch the environment: `Host` is request-scoped, not process-global, so there is no
test race (the `debug_surface_requested` rationale was about env, not headers).

Tests: `setup_status_reports_backend_label` now sends `Host: edge-1.example.com:443`
and asserts the label is the bare origin `edge-1.example.com` (port trimmed). A new
`setup_status_reports_null_backend_without_host` asserts `null` when no Host is sent.
GATE-P0 106 passed / 0 failed / 4 ignored; GATE-DOCS 6/6 clean (49 line-refs).
This commit is contained in:
anonsaber 2026-10-14 08:48:11 +08:00
parent 26e916b79d
commit 34efae84ff
13 changed files with 91 additions and 88 deletions

View File

@ -10,10 +10,5 @@ CONFIG_ENCRYPTION_KEY=REPLACE_WITH_64_HEX_CHAR_RANDOM_KEY
# PORT is an optional platform override (default: 8080). There is no RUN_MODE: the # PORT is an optional platform override (default: 8080). There is no RUN_MODE: the
# legacy env parser was removed, and webhook plus reconcile traffic share one router. # legacy env parser was removed, and webhook plus reconcile traffic share one router.
# BACKEND_ID is an optional per-instance observability label echoed as `backend` in
# /api/status, so a probe through the load balancer can tell which origin answered
# (the baked build `version` is identical across instances of one image). Falls back
# to HOSTNAME. Not a secret, not a connection setting, never persisted.
BACKEND_ID=
# Business secrets (Telegram/JMAP/LLM/auth) must be supplied once via HTTPS /api/bootstrap; # Business secrets (Telegram/JMAP/LLM/auth) must be supplied once via HTTPS /api/bootstrap;
# they are then persisted in Redis and are never echoed by the API. # they are then persisted in Redis and are never echoed by the API.

View File

@ -178,7 +178,7 @@ if 带 verificationCode:
- **回调 URL**: `https://messageweave.motofans.club/push/jmap` - **回调 URL**: `https://messageweave.motofans.club/push/jmap`
- **后端仓库**: `/home/okabe/Repo/messageweave` - **后端仓库**: `/home/okabe/Repo/messageweave`
- **双 remote**: `origin`(motofans.club)+ `upstream`(GitHub fork) - **双 remote**: `origin`(motofans.club)+ `upstream`(GitHub fork)
- **测试基线**: Rust 105 passed / 0 failed / 4 ignored,GATE-DOCS 6/6 PASS - **测试基线**: Rust 106 passed / 0 failed / 4 ignored,GATE-DOCS 6/6 PASS
- **最新 commit**: `f9160c0`(已 push 双 remote) - **最新 commit**: `f9160c0`(已 push 双 remote)
--- ---

View File

@ -118,7 +118,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \
bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12' bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12'
``` ```
**Current baseline: 105 passed / 0 failed / 4 ignored** (Add 4 cursor/walk tests + 1 `/api/status` backend-label self-report test, retested 2026-10-14; ignored: `real_server_tests::session_list_and_read_smoke` and `debug::tests::debug_config_reports_timezone_of_business_configured_app` require external real JMAP Server credentials, `real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` and `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` require `REDIS_TEST_URL`). **Current baseline: 106 passed / 0 failed / 4 ignored** (4 cursor/walk tests + 2 `/api/status` Host-header tests (label echo + null fallback)), retested 2026-10-14; ignored: `real_server_tests::session_list_and_read_smoke` and `debug::tests::debug_config_reports_timezone_of_business_configured_app` require external real JMAP Server credentials, `real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` and `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` require `REDIS_TEST_URL`).
**Note**: The bash `-lc` script of `docker run` must be wrapped in **single quotes**. Using double quotes will first expand `$PWD` / `$PATH` on the host, and cargo will not be found in the container. **Note**: The bash `-lc` script of `docker run` must be wrapped in **single quotes**. Using double quotes will first expand `$PWD` / `$PATH` on the host, and cargo will not be found in the container.

View File

@ -118,7 +118,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \
bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12' bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12'
``` ```
**当前基线:105 passed / 0 failed / 4 ignored**(新增 4 个游标解析与 walk 分页单测 + 1 个 `/api/status` backend 标签自报单测后复测,2026-10-14;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app` 需外部真实 JMAP 服务器凭据,`real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` 与 `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` 需 `REDIS_TEST_URL`)。 **当前基线:106 passed / 0 failed / 4 ignored**(4 个游标解析与 walk 分页单测 + 2 个 `/api/status` Host 头单测(标签回显 + null 回退))后复测,2026-10-14;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app` 需外部真实 JMAP 服务器凭据,`real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` 与 `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` 需 `REDIS_TEST_URL`)。
**注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。 **注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。

View File

@ -366,7 +366,7 @@ Testing: **No integrated test directory**, and no stand-alone util tool module.
> Compatibility instructions. > Compatibility instructions.
> **Actual implementation** (`ARCH-CONFIG-ENV`): process startup read-only **2** process-level credentials plus listening port (all via `std::env::var` in `main`, keyed by `REDIS_URL` / `CONFIG_ENCRYPTION_KEY` / `PORT`, > **Actual implementation** (`ARCH-CONFIG-ENV`): process startup read-only **2** process-level credentials plus listening port (all via `std::env::var` in `main`, keyed by `REDIS_URL` / `CONFIG_ENCRYPTION_KEY` / `PORT`,
> plus optional `DEBUG_TOKEN` and the `/api/status` label `BACKEND_ID`), **do not use figment, do not use TOML/configuration file**. > plus optional `DEBUG_TOKEN`), **do not use figment, do not use TOML/configuration file**.
> **Business configuration is no longer read from environment variables**: The fields in the following table are all loaded by the `config` layer using serde deserialization through the Redis business configuration. > **Business configuration is no longer read from environment variables**: The fields in the following table are all loaded by the `config` layer using serde deserialization through the Redis business configuration.
> And do fail-closed verification through `validate_business_wire` / `validate_nonblank`. > And do fail-closed verification through `validate_business_wire` / `validate_nonblank`.
> Historically existing `Config::from_env()` full env parsing path **removed** (see `docs/retired.md` for registration), > Historically existing `Config::from_env()` full env parsing path **removed** (see `docs/retired.md` for registration),
@ -420,7 +420,7 @@ Testing: **No integrated test directory**, and no stand-alone util tool module.
### 7.3.1 Configuration Management API ### 7.3.1 Configuration Management API
- `GET /` provides a SPA embedded in the Rust binary; `/assets/config.js` and `/assets/styles.css` provide page resources. The service does not read or write local files while running (`C-NO-LOCAL-WRITE`). - `GET /` provides a SPA embedded in the Rust binary; `/assets/config.js` and `/assets/styles.css` provide page resources. The service does not read or write local files while running (`C-NO-LOCAL-WRITE`).
- `GET /api/status` publicly returns `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string, "backend": string|null }`, which only lists the missing environment variable names. When `REDIS_URL` or `CONFIG_ENCRYPTION_KEY` is missing, SPA only displays the configuration boot status and missing variables; the management session authorization area is displayed only after the service status is confirmed to be ready=true. `version` is the `BUILD_VERSION` (`<git-sha or nogit>+<UTC build time>`) baked by `build.rs` during compilation. The SPA no longer renders it, so the API is the only way to confirm a deployment landed. `backend` is the per-instance label read once at startup from `BACKEND_ID` (falling back to `HOSTNAME`); `version` is identical across instances built from the same image, so `backend` is what lets a probe through the load balancer tell which origin answered. `null` means no label was configured (both env vars unset); two such instances are indistinguishable by this field by design. - `GET /api/status` publicly returns `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string, "backend": string|null }`, which only lists the missing environment variable names. When `REDIS_URL` or `CONFIG_ENCRYPTION_KEY` is missing, SPA only displays the configuration boot status and missing variables; the management session authorization area is displayed only after the service status is confirmed to be ready=true. `version` is the `BUILD_VERSION` (`<git-sha or nogit>+<UTC build time>`) baked by `build.rs` during compilation. The SPA no longer renders it, so the API is the only way to confirm a deployment landed. `backend` echoes the request `Host` header (trimmed of the default https/http port); when the request comes through the Cloudflare Worker load balancer (`ARCH-LB-WORKER`), that header is the public address the LB routed this instance as — which is what lets a probe through the load balancer tell which origin answered. The label is derived per-request, not from any startup env, so it never drifts from the LB config. `null` means the request carried no usable `Host`.
- `POST /api/admin/session` accepts `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>` and returns `{ "session": "<opaque>", "expires_in": 1800 }`; the admin session only saves the digest in Redis and expires after 1,800 seconds. `POST /api/admin/session/revoke` revokes the current session and returns `204` successfully. - `POST /api/admin/session` accepts `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>` and returns `{ "session": "<opaque>", "expires_in": 1800 }`; the admin session only saves the digest in Redis and expires after 1,800 seconds. `POST /api/admin/session/revoke` revokes the current session and returns `204` successfully.
- The admin page only saves the session in JavaScript memory. Request settings `credentials: omit`, `cache: no-store`, without using cookies, localStorage or sessionStorage. The admin API accepts a valid admin session; the compatibility path also accepts `WORKER_TOKEN`. Worker transparently transmits the authentication header (`SAF-LB-PASSTHRU`) as it is. - The admin page only saves the session in JavaScript memory. Request settings `credentials: omit`, `cache: no-store`, without using cookies, localStorage or sessionStorage. The admin API accepts a valid admin session; the compatibility path also accepts `WORKER_TOKEN`. Worker transparently transmits the authentication header (`SAF-LB-PASSTHRU`) as it is.
- `GET /api/config` and `PUT /api/config` only read and write non-sensitive running parameters: - `GET /api/config` and `PUT /api/config` only read and write non-sensitive running parameters:
@ -560,7 +560,7 @@ The design target form once included six additional variants: `Jmap(#[from] jmap
- axum adopts **0.8** (`ARCH-AXUM-08`); if subsequent review decides to adjust the version, Cargo.toml will prevail and this section will be synchronized. - axum adopts **0.8** (`ARCH-AXUM-08`); if subsequent review decides to adjust the version, Cargo.toml will prevail and this section will be synchronized.
- Currently SSE/WebSocket/long polling/SQLite/local volumes (`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`) are not implemented; entrance authentication (`R1`/`SAF-AUTH-*`) is implemented as a fail-closed hard access control, and the Push, Streams worker and `/reconcile` business paths after authentication have been implemented. - Currently SSE/WebSocket/long polling/SQLite/local volumes (`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`) are not implemented; entrance authentication (`R1`/`SAF-AUTH-*`) is implemented as a fail-closed hard access control, and the Push, Streams worker and `/reconcile` business paths after authentication have been implemented.
**Phase 0 P0 Gate (`GATE-P0`) - Passed. It was 45 passed / 0 failed / 1 ignored when it was first frozen, and has continued to grow in subsequent rounds ④–⑥. The current baseline is 105 passed / 0 failed / 4 ignored (2026-10-14 retest; see the `TEST_BASELINE` line of `docs/charter.md` for the authoritative value). The following are the criteria at the time of stage 0, which are retained for historical records: ** **Phase 0 P0 Gate (`GATE-P0`) - Passed. It was 45 passed / 0 failed / 1 ignored when it was first frozen, and has continued to grow in subsequent rounds ④–⑥. The current baseline is 106 passed / 0 failed / 4 ignored (2026-10-14 retest; see the `TEST_BASELINE` line of `docs/charter.md` for the authoritative value). The following are the criteria at the time of stage 0, which are retained for historical records: **
1. `cargo fmt --check` passes (no format difference). 1. `cargo fmt --check` passes (no format difference).
2. `cargo clippy --all-targets -- -D warnings` passes (zero warnings; crate-level `allow` is disabled). 2. `cargo clippy --all-targets -- -D warnings` passes (zero warnings; crate-level `allow` is disabled).
3. `cargo test` passed (including routing/configuration minimum test). 3. `cargo test` passed (including routing/configuration minimum test).

View File

@ -366,7 +366,7 @@ message-weave/
> 兼容说明。 > 兼容说明。
> **实际实现**(`ARCH-CONFIG-ENV`):进程启动只读 **2 个**进程级凭据加监听端口(均经 `main` 里的 `std::env::var`,键名 `REDIS_URL` / `CONFIG_ENCRYPTION_KEY` / `PORT`, > **实际实现**(`ARCH-CONFIG-ENV`):进程启动只读 **2 个**进程级凭据加监听端口(均经 `main` 里的 `std::env::var`,键名 `REDIS_URL` / `CONFIG_ENCRYPTION_KEY` / `PORT`,
> 另加可选 `DEBUG_TOKEN` 与 `/api/status` 标签 `BACKEND_ID`),**不使用 figment、不使用 TOML/配置文件**。 > 另加可选 `DEBUG_TOKEN`),**不使用 figment、不使用 TOML/配置文件**。
> **业务配置不再从环境变量读取**:下表字段全部经 Redis 业务配置由 `config` 层以 serde 反序列化装载, > **业务配置不再从环境变量读取**:下表字段全部经 Redis 业务配置由 `config` 层以 serde 反序列化装载,
> 并经 `validate_business_wire` / `validate_nonblank` 做 fail-closed 校验。 > 并经 `validate_business_wire` / `validate_nonblank` 做 fail-closed 校验。
> 历史上存在的 `Config::from_env()` 完整 env 解析路径**已删除**(登记见 `docs/retired.md`), > 历史上存在的 `Config::from_env()` 完整 env 解析路径**已删除**(登记见 `docs/retired.md`),
@ -420,7 +420,7 @@ message-weave/
### 7.3.1 配置管理 API ### 7.3.1 配置管理 API
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。 - `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
- `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string, "backend": string|null }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。`version` 是 `build.rs` 在编译期烘焙的 `BUILD_VERSION`(`<git-sha 或 nogit>+<UTC 构建时间>`)。SPA 已不再渲染它,确认部署落地只能查这个接口。`backend` 是启动时一次性读取 `BACKEND_ID`(回退到 `HOSTNAME`)的每实例标签;同一镜像构建的实例 `version` 相同,所以 `backend` 才是让穿过负载均衡器的探针分辨命中的是哪个 origin 的字段。`null` 表示未配置标签(两个 env 都未设置);两个都不设的实例在该字段上不可区分,这是设计如此。 - `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string, "backend": string|null }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。`version` 是 `build.rs` 在编译期烘焙的 `BUILD_VERSION`(`<git-sha 或 nogit>+<UTC 构建时间>`)。SPA 已不再渲染它,确认部署落地只能查这个接口。`backend` 回显请求的 `Host` 头(去掉默认 https/http 端口);请求穿过 Cloudflare Worker 负载均衡器(`ARCH-LB-WORKER`)时,该头就是 LB 把该实例路由为的公开地址——让穿过负载均衡器的探针分辨命中的是哪个 origin。标签逐请求推导,不来自任何启动期 env,永远不会与 LB 配置漂移。`null` 表示请求未携带可用的 `Host`。
- `POST /api/admin/session` 接受 `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>`,返回 `{ "session": "<opaque>", "expires_in": 1800 }`;Redis 中保存的是会话令牌的摘要,30 分钟后过期。`POST /api/admin/session/revoke` 可立即撤销会话,成功返回 `204`。 - `POST /api/admin/session` 接受 `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>`,返回 `{ "session": "<opaque>", "expires_in": 1800 }`;Redis 中保存的是会话令牌的摘要,30 分钟后过期。`POST /api/admin/session/revoke` 可立即撤销会话,成功返回 `204`。
- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。 - 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。
- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数: - `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数:
@ -560,7 +560,7 @@ pub enum BotError {
- axum 采用 **0.8**(`ARCH-AXUM-08`);如后续审核决定调整版本,以 Cargo.toml 为准并同步本节。 - axum 采用 **0.8**(`ARCH-AXUM-08`);如后续审核决定调整版本,以 Cargo.toml 为准并同步本节。
- 当前不实现 SSE/WebSocket/长轮询/SQLite/本地卷(`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);入口鉴权(`R1`/`SAF-AUTH-*`)作为 fail-closed 硬门禁落地,鉴权之后的 Push、Streams worker 和 `/reconcile` 业务路径已实现。 - 当前不实现 SSE/WebSocket/长轮询/SQLite/本地卷(`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);入口鉴权(`R1`/`SAF-AUTH-*`)作为 fail-closed 硬门禁落地,鉴权之后的 Push、Streams worker 和 `/reconcile` 业务路径已实现。
**阶段0 P0 门禁(`GATE-P0`)——已通过。首次冻结时为 45 passed / 0 failed / 1 ignored,此后随 ④–⑥ 轮实现持续增长,当前基线为 105 passed / 0 failed / 4 ignored(2026-10-14 复测;权威值见 `docs/charter.md` 的 `TEST_BASELINE` 行)。以下为阶段0 当时的判据,保留作历史记录:** **阶段0 P0 门禁(`GATE-P0`)——已通过。首次冻结时为 45 passed / 0 failed / 1 ignored,此后随 ④–⑥ 轮实现持续增长,当前基线为 106 passed / 0 failed / 4 ignored(2026-10-14 复测;权威值见 `docs/charter.md` 的 `TEST_BASELINE` 行)。以下为阶段0 当时的判据,保留作历史记录:**
1. `cargo fmt --check` 通过(无格式差异)。 1. `cargo fmt --check` 通过(无格式差异)。
2. `cargo clippy --all-targets -- -D warnings` 通过(零告警;禁 crate 级 `allow`)。 2. `cargo clippy --all-targets -- -D warnings` 通过(零告警;禁 crate 级 `allow`)。
3. `cargo test` 通过(含路由/配置最小测试)。 3. `cargo test` 通过(含路由/配置最小测试)。

View File

@ -21,7 +21,7 @@ The TTL green light for real machines has already been passed using the Upstash
| 3 | Telegram inbound / Stalwart `PushSubscription` joint debugging | Closed (real traffic has been driven, please see `README.md` §3.7 for operation steps) | | 3 | Telegram inbound / Stalwart `PushSubscription` joint debugging | Closed (real traffic has been driven, please see `README.md` §3.7 for operation steps) |
| 4 | `/worker` is not in the deployment document | Closed (`README.md` §3.10 has been added) | | 4 | `/worker` is not in the deployment document | Closed (`README.md` §3.10 has been added) |
Access control: `cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` all green; `cargo test --locked` **105 passed / 0 failed / 4 ignored** (2 TTL assertions out of 4 `#[ignore]` have been run with real Upstash: `REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**; the remaining 2 require real JMAP servers, this capability has been verified in staging joint debugging); documentation gate is 6/6 PASS, including 49 line number anchor non-null verifications. Access control: `cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` all green; `cargo test --locked` **106 passed / 0 failed / 4 ignored** (2 TTL assertions out of 4 `#[ignore]` have been run with real Upstash: `REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**; the remaining 2 require real JMAP servers, this capability has been verified in staging joint debugging); documentation gate is 6/6 PASS, including 49 line number anchor non-null verifications.
Item 5 is not within the stage goal: multi-account product decision-making, which has been determined not to be done by the user on 2026-09-28. The decision boundary and future changes will be discussed in §3. Item 5 is not within the stage goal: multi-account product decision-making, which has been determined not to be done by the user on 2026-09-28. The decision boundary and future changes will be discussed in §3.

View File

@ -21,7 +21,7 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),
| 3 | Telegram 入站 / Stalwart `PushSubscription` 联调 | 已关闭(真实流量已驱动,操作步骤见 `README.md` §3.7) | | 3 | Telegram 入站 / Stalwart `PushSubscription` 联调 | 已关闭(真实流量已驱动,操作步骤见 `README.md` §3.7) |
| 4 | `/worker` 未在部署文档里 | 已关闭(`README.md` §3.10 已补) | | 4 | `/worker` 未在部署文档里 | 已关闭(`README.md` §3.10 已补) |
门禁:`cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` 全绿;`cargo test --locked` **105 passed / 0 failed / 4 ignored**(4 个 `#[ignore]` 里 2 个 TTL 断言已用真实 Upstash 跑过:`REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**;剩 2 个需要真实 JMAP 服务器,该能力已在 staging 联调验证);文档门 6/6 全绿,含 49 处行号锚点非空校验。 门禁:`cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` 全绿;`cargo test --locked` **106 passed / 0 failed / 4 ignored**(4 个 `#[ignore]` 里 2 个 TTL 断言已用真实 Upstash 跑过:`REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**;剩 2 个需要真实 JMAP 服务器,该能力已在 staging 联调验证);文档门 6/6 全绿,含 49 处行号锚点非空校验。
第 5 项不在阶段目标内:多账号产品决策,2026-09-28 已由用户定为不做,决定边界与将来要做的改动面见 §3。 第 5 项不在阶段目标内:多账号产品决策,2026-09-28 已由用户定为不做,决定边界与将来要做的改动面见 §3。

View File

@ -281,12 +281,13 @@ Response conventions:
safe to poll. `version` is the `BUILD_VERSION` string baked in by `build.rs` safe to poll. `version` is the `BUILD_VERSION` string baked in by `build.rs`
(`<git-sha-or-nogit>+<UTC build time>`) — identical across every instance built from the same (`<git-sha-or-nogit>+<UTC build time>`) — identical across every instance built from the same
image, so it answers "did this deploy land?" but not "which instance answered?". `backend` image, so it answers "did this deploy land?" but not "which instance answered?". `backend`
answers that: a per-instance label read once at startup from `BACKEND_ID` (falling back to answers that: the `Host` header of the request itself, trimmed of the default https/http
`HOSTNAME`, which container runtimes set), threaded into `AppState::backend_id` and echoed port (`example.com:443` -> `example.com`). When the request comes through the Cloudflare
here. It is an observability label, not a connection secret and not persisted. `null` means Worker load balancer (`ARCH-LB-WORKER`), that header is the public address the LB routed
neither env var was set; two such instances are indistinguishable by this field, so a probe this instance as — which is exactly what a probe needs to tell origins apart. No startup
that needs to tell origins apart must set `BACKEND_ID` per instance at deploy time. The SPA env var is involved: the label is derived per-request, so it never drifts from the LB
no longer renders `version` — the API is now the only way to confirm a deploy landed. config. `null` means the request carried no usable `Host` (e.g. a bare health check). The
SPA no longer renders `version` — the API is now the only way to confirm a deploy landed.
- `/debug/*` returns **503** in exactly one place: `POST /debug/notify` when the business - `/debug/*` returns **503** in exactly one place: `POST /debug/notify` when the business
config is not loaded or there is no outbound client — `service_unavailable` with config is not loaded or there is no outbound client — `service_unavailable` with
`Retry-After: 30`. The three probe endpoints instead report failure `Retry-After: 30`. The three probe endpoints instead report failure

View File

@ -275,7 +275,7 @@ env var — 并且“DEBUG_TOKEN”非空**(“SAF-DEBUG-GATE”;门被读
`{"ready": <bool>, "mode": "configured"|"configuration-setup", "missing": [...], "version": "<build-fingerprint>", "backend": "<id>"|null}`。 `{"ready": <bool>, "mode": "configured"|"configuration-setup", "missing": [...], "version": "<build-fingerprint>", "backend": "<id>"|null}`。
`ready` 为 `false`、`missing` 列出缺少的必需变量名(`REDIS_URL`、 `ready` 为 `false`、`missing` 列出缺少的必需变量名(`REDIS_URL`、
`CONFIG_ENCRYPTION_KEY`)——路由本身永不报错,可安全轮询。`version` 是 `build.rs` 烘焙的 `BUILD_VERSION` 字符串 `CONFIG_ENCRYPTION_KEY`)——路由本身永不报错,可安全轮询。`version` 是 `build.rs` 烘焙的 `BUILD_VERSION` 字符串
(`<git-sha 或 nogit>+<UTC 构建时间>`)——同一镜像构建的每个实例都相同,因此它回答「本次部署是否落地」,但不回答「命中的是哪个实例」。`backend` 回答后者:每实例标签,启动时一次性读取 `BACKEND_ID`(回退容器运行时设置的 `HOSTNAME`),穿入 `AppState::backend_id` 并在此回显。它是观测标签,不是连接密钥,也不持久化。`null` 表示两个 env 都未设置;两个都不设的实例在该字段上不可区分,所以需要分辨 origin 的探针必须在部署时为每个实例设置 `BACKEND_ID`。SPA 已不再渲染 `version`,确认部署落地只能查这个接口。 (`<git-sha 或 nogit>+<UTC 构建时间>`)——同一镜像构建的每个实例都相同,因此它回答「本次部署是否落地」,但不回答「命中的是哪个实例」。`backend` 回答后者:请求本身的 `Host` 头,去掉默认 https/http 端口(`example.com:443` -> `example.com`)。请求穿过 Cloudflare Worker 负载均衡器(`ARCH-LB-WORKER`)时,该头就是 LB 把该实例路由为的公开地址——正是探针分辨 origin 所需。不涉及启动期 env:标签逐请求推导,永远不会与 LB 配置漂移。`null` 表示请求未携带可用的 `Host`(如裸健康检查)。SPA 已不再渲染 `version`,确认部署落地只能查这个接口。
- `/debug/*` 在一个地方返回 **503**:业务执行时`POST /debug/notify` - `/debug/*` 在一个地方返回 **503**:业务执行时`POST /debug/notify`
配置未加载或没有出站客户端 — `service_unavailable` 配置未加载或没有出站客户端 — `service_unavailable`
`重试时间:30`。三个探测端点反而报告失败 `重试时间:30`。三个探测端点反而报告失败

View File

@ -313,7 +313,6 @@ mod tests {
state, state,
HashSet::new(), HashSet::new(),
Arc::new(NoopWorker), Arc::new(NoopWorker),
None,
runtime_provider(OutboundConfig::default()), runtime_provider(OutboundConfig::default()),
SecretString::new(String::new()), SecretString::new(String::new()),
debug_token, debug_token,

View File

@ -26,23 +26,6 @@ fn debug_surface_requested(debug_arg: bool, debug_enabled_var: Option<&str>) ->
) )
} }
/// Per-instance observability label for `/api/status` (see `AppState::backend_id`). Read once at
/// startup so request handlers never touch the environment. `BACKEND_ID` wins; otherwise
/// `HOSTNAME` (set by container runtimes) gives out-of-the-box distinction between instances.
/// `None` only when both are unset, so a probe honestly reports "not labeled" instead of a
/// misleading default — two instances that both skip it are indistinguishable by design until an
/// operator sets `BACKEND_ID`, which is a deploy-time concern, not a connection secret.
fn backend_id_from_env() -> Option<String> {
std::env::var("BACKEND_ID")
.ok()
.filter(|value| !value.is_empty())
.or_else(|| {
std::env::var("HOSTNAME")
.ok()
.filter(|value| !value.is_empty())
})
}
use crate::channel::telegram::TelegramClient; use crate::channel::telegram::TelegramClient;
use crate::domain::jmap::{client::JmapClientBackend, JmapService}; use crate::domain::jmap::{client::JmapClientBackend, JmapService};
use config::{encryption_key_from_env, Config}; use config::{encryption_key_from_env, Config};
@ -67,7 +50,6 @@ async fn main() -> Result<(), error::BotError> {
.parse() .parse()
.map_err(|e| error::BotError::Config(format!("invalid PORT: {e}")))?; .map_err(|e| error::BotError::Config(format!("invalid PORT: {e}")))?;
let listener = tokio::net::TcpListener::bind(addr).await?; let listener = tokio::net::TcpListener::bind(addr).await?;
let backend_id = backend_id_from_env();
let mut missing = Vec::new(); let mut missing = Vec::new();
let redis_value = match std::env::var("REDIS_URL") { let redis_value = match std::env::var("REDIS_URL") {
Ok(value) if !value.trim().is_empty() => value, Ok(value) if !value.trim().is_empty() => value,
@ -91,7 +73,7 @@ async fn main() -> Result<(), error::BotError> {
?missing, ?missing,
"required bootstrap environment is incomplete; serving setup mode" "required bootstrap environment is incomplete; serving setup mode"
); );
let app = router_configuration_setup(missing, backend_id).merge(web::router()); let app = router_configuration_setup(missing).merge(web::router());
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
return Ok(()); return Ok(());
} }
@ -216,7 +198,6 @@ async fn main() -> Result<(), error::BotError> {
redis, redis,
allowlist, allowlist,
Arc::new(WorkerHandle::new(worker)), Arc::new(WorkerHandle::new(worker)),
backend_id,
runtime, runtime,
admin_token, admin_token,
debug_token, debug_token,

View File

@ -104,11 +104,6 @@ pub(crate) struct AppState {
/// `--debug` AND `DEBUG_TOKEN` was set. `debug_router` is mounted iff this is `Some`, so the /// `--debug` AND `DEBUG_TOKEN` was set. `debug_router` is mounted iff this is `Some`, so the
/// `/debug/*` surface is absent by default. `SAF-DEBUG-AUTH`: compared in constant time. /// `/debug/*` surface is absent by default. `SAF-DEBUG-AUTH`: compared in constant time.
pub(crate) debug_token: Option<SecretString>, pub(crate) debug_token: Option<SecretString>,
/// Per-instance observability label echoed in `/api/status`, so a probe through the load
/// balancer can tell which origin answered — `version` alone is identical across instances
/// built from the same image. Read once at startup from `BACKEND_ID` (falls back to
/// `HOSTNAME`); not a connection secret, not persisted. `None` only when both are unset.
pub(crate) backend_id: Option<String>,
} }
impl From<AuthSecrets> for AuthState { impl From<AuthSecrets> for AuthState {
@ -218,18 +213,45 @@ async fn ready(State(app): State<AppState>) -> Response {
} }
} }
async fn setup_status(State(app): State<AppState>) -> Response { async fn setup_status(State(app): State<AppState>, headers: HeaderMap) -> Response {
let ready = app.setup_missing.is_empty(); let ready = app.setup_missing.is_empty();
axum::Json(serde_json::json!({ axum::Json(serde_json::json!({
"ready": ready, "ready": ready,
"mode": if ready { "configured" } else { "configuration-setup" }, "mode": if ready { "configured" } else { "configuration-setup" },
"missing": app.setup_missing.as_ref(), "missing": app.setup_missing.as_ref(),
"version": BUILD_VERSION, "version": BUILD_VERSION,
"backend": &app.backend_id, "backend": backend_label_from_headers(&headers),
})) }))
.into_response() .into_response()
} }
/// The host this request arrived for, echoed as `backend` in `/api/status`.
///
/// When the request comes through the Cloudflare Worker load balancer
/// (`ARCH-LB-WORKER`), that Worker preserves the inbound `Host` header while rewriting the
/// target to a backend origin (`joinUrl` builds `origin + path + query`), so `Host` is exactly
/// the public address the load balancer routed this instance as — which is what a probe needs
/// to tell "which backend answered" when several instances sit behind one hostname. That is
/// more useful than the baked `BUILD_VERSION`, which is identical across every instance built
/// from the same image. The port is dropped when it is the scheme default
/// (`example.com:443` -> `example.com`) so the reported label matches the configured origin.
/// `None` when the request carries no usable `Host`.
fn backend_label_from_headers(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(header::HOST)?.to_str().ok()?.trim();
if raw.is_empty() {
return None;
}
// Trim the default https/http port so the label mirrors a bare origin.
let host = match raw.rsplit_once(':') {
Some((host, port)) if port == "443" || port == "80" => host,
_ => raw,
};
if host.is_empty() {
return None;
}
Some(host.to_owned())
}
async fn telegram_webhook( async fn telegram_webhook(
State(app): State<AppState>, State(app): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@ -1930,7 +1952,6 @@ pub fn router_with_state<S: ReliableState + 'static>(
state, state,
allowlist, allowlist,
Arc::new(NoopWorker), Arc::new(NoopWorker),
None,
) )
} }
@ -1946,7 +1967,7 @@ pub fn router_with_state<S: ReliableState + 'static>(
/// 404 removes that ambiguity for clients, probes and the SPA alike. The SPA bootstrap card is /// 404 removes that ambiguity for clients, probes and the SPA alike. The SPA bootstrap card is
/// driven by `/api/status` alone (`web/config.js` reveals the admin-session card only when /// driven by `/api/status` alone (`web/config.js` reveals the admin-session card only when
/// `ready === true`), so nothing the status page renders is lost. /// `ready === true`), so nothing the status page renders is lost.
pub fn router_configuration_setup(missing: Vec<String>, backend_id: Option<String>) -> Router { pub fn router_configuration_setup(missing: Vec<String>) -> Router {
let worker_handle = Arc::new(WorkerHandle::new(Arc::new(NoopWorker))); let worker_handle = Arc::new(WorkerHandle::new(Arc::new(NoopWorker)));
let app_state = AppState { let app_state = AppState {
auth: AuthState { auth: AuthState {
@ -1965,7 +1986,6 @@ pub fn router_configuration_setup(missing: Vec<String>, backend_id: Option<Strin
business_revision: Arc::new(RwLock::new(0)), business_revision: Arc::new(RwLock::new(0)),
setup_missing: Arc::new(missing), setup_missing: Arc::new(missing),
reload: Arc::new(ReloadCoordinator::new(worker_handle, None)), reload: Arc::new(ReloadCoordinator::new(worker_handle, None)),
backend_id,
}; };
Router::new() Router::new()
.route("/api/status", get(setup_status)) .route("/api/status", get(setup_status))
@ -1980,7 +2000,6 @@ pub fn router_with_worker_state<S: ReliableState + 'static>(
state: S, state: S,
allowlist: HashSet<i64>, allowlist: HashSet<i64>,
worker_handler: Arc<dyn WorkerHandler>, worker_handler: Arc<dyn WorkerHandler>,
backend_id: Option<String>,
) -> Router { ) -> Router {
router_with_worker_state_and_runtime( router_with_worker_state_and_runtime(
secrets, secrets,
@ -1988,7 +2007,6 @@ pub fn router_with_worker_state<S: ReliableState + 'static>(
state, state,
allowlist, allowlist,
worker_handler, worker_handler,
backend_id,
runtime_provider(OutboundConfig::default()), runtime_provider(OutboundConfig::default()),
) )
} }
@ -1999,7 +2017,6 @@ pub fn router_with_worker_state_and_runtime<S: ReliableState + 'static>(
state: S, state: S,
allowlist: HashSet<i64>, allowlist: HashSet<i64>,
worker_handler: Arc<dyn WorkerHandler>, worker_handler: Arc<dyn WorkerHandler>,
backend_id: Option<String>,
runtime: RuntimeConfigProvider, runtime: RuntimeConfigProvider,
) -> Router { ) -> Router {
router_with_worker_state_runtime_bootstrap( router_with_worker_state_runtime_bootstrap(
@ -2008,7 +2025,6 @@ pub fn router_with_worker_state_and_runtime<S: ReliableState + 'static>(
state, state,
allowlist, allowlist,
worker_handler, worker_handler,
backend_id,
runtime, runtime,
SecretString::new(String::new()), SecretString::new(String::new()),
None, None,
@ -2025,7 +2041,6 @@ pub fn router_with_worker_state_runtime_bootstrap<S: ReliableState + 'static>(
state: S, state: S,
allowlist: HashSet<i64>, allowlist: HashSet<i64>,
worker_handler: Arc<dyn WorkerHandler>, worker_handler: Arc<dyn WorkerHandler>,
backend_id: Option<String>,
runtime: RuntimeConfigProvider, runtime: RuntimeConfigProvider,
admin_token: SecretString, admin_token: SecretString,
debug_token: Option<SecretString>, debug_token: Option<SecretString>,
@ -2036,7 +2051,6 @@ pub fn router_with_worker_state_runtime_bootstrap<S: ReliableState + 'static>(
state, state,
allowlist, allowlist,
worker_handler, worker_handler,
backend_id,
runtime, runtime,
admin_token, admin_token,
debug_token, debug_token,
@ -2054,7 +2068,6 @@ fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>
state: S, state: S,
allowlist: HashSet<i64>, allowlist: HashSet<i64>,
worker_handler: Arc<dyn WorkerHandler>, worker_handler: Arc<dyn WorkerHandler>,
backend_id: Option<String>,
runtime: RuntimeConfigProvider, runtime: RuntimeConfigProvider,
admin_token: SecretString, admin_token: SecretString,
debug_token: Option<SecretString>, debug_token: Option<SecretString>,
@ -2076,7 +2089,6 @@ fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>
business_revision: Arc::new(RwLock::new(0)), business_revision: Arc::new(RwLock::new(0)),
setup_missing: Arc::new(setup_missing), setup_missing: Arc::new(setup_missing),
reload, reload,
backend_id,
}; };
let mut router = Router::new() let mut router = Router::new()
.route("/webhook/tg", post(telegram_webhook)) .route("/webhook/tg", post(telegram_webhook))
@ -2227,7 +2239,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn configuration_setup_status_lists_only_missing_names() { async fn configuration_setup_status_lists_only_missing_names() {
let response = router_configuration_setup(vec!["REDIS_URL".into()], None) let response = router_configuration_setup(vec!["REDIS_URL".into()])
.oneshot( .oneshot(
Request::builder() Request::builder()
.method("GET") .method("GET")
@ -2249,15 +2261,17 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn setup_status_reports_backend_label() { async fn setup_status_reports_backend_label() {
// `backend` is threaded from the router builder into `/api/status` so a probe through // `backend` echoes the request `Host` header: when a probe goes through the load
// the load balancer can tell which origin answered — `version` alone is identical // balancer, that header is the public address the LB routed this instance as, which is
// across instances built from the same image. `main` reads `BACKEND_ID`/`HOSTNAME` at // what tells "which backend answered" — `version` alone is identical across instances
// startup and passes a real value; here the value is echoed verbatim end-to-end. // built from the same image. The default https port is trimmed so the label mirrors a
let response = router_configuration_setup(vec![], Some("edge-1".into())) // bare origin.
let response = router_configuration_setup(vec![])
.oneshot( .oneshot(
Request::builder() Request::builder()
.method("GET") .method("GET")
.uri("/api/status") .uri("/api/status")
.header("host", "edge-1.example.com:443")
.body(Body::empty()) .body(Body::empty())
.unwrap(), .unwrap(),
) )
@ -2268,15 +2282,32 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["backend"], "edge-1"); assert_eq!(value["backend"], "edge-1.example.com");
}
#[tokio::test]
async fn setup_status_reports_null_backend_without_host() {
let response = router_configuration_setup(vec![])
.oneshot(
Request::builder()
.method("GET")
.uri("/api/status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(value["backend"].is_null());
} }
#[tokio::test] #[tokio::test]
async fn configuration_setup_status_lists_both_bootstrap_requirements() { async fn configuration_setup_status_lists_both_bootstrap_requirements() {
let response = router_configuration_setup( let response =
vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()], router_configuration_setup(vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()])
None,
)
.oneshot( .oneshot(
Request::builder() Request::builder()
.method("GET") .method("GET")
@ -2297,10 +2328,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn configuration_setup_mounts_only_status_and_probes() { async fn configuration_setup_mounts_only_status_and_probes() {
let router = router_configuration_setup( let router =
vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()], router_configuration_setup(vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()]);
None,
);
let token = "Bearer CONFIG_ENCRYPTION_KEY-candidate"; let token = "Bearer CONFIG_ENCRYPTION_KEY-candidate";
// The status surface and both probes stay reachable: the SPA bootstrap card is driven // The status surface and both probes stay reachable: the SPA bootstrap card is driven
@ -2356,7 +2385,6 @@ mod tests {
MemoryState::default(), MemoryState::default(),
HashSet::new(), HashSet::new(),
Arc::new(NoopWorker), Arc::new(NoopWorker),
None,
runtime_provider(OutboundConfig::default()), runtime_provider(OutboundConfig::default()),
SecretString::new("acl-root".into()), SecretString::new("acl-root".into()),
None, None,
@ -3342,7 +3370,6 @@ mod tests {
state.clone(), state.clone(),
HashSet::new(), HashSet::new(),
handler, handler,
None,
); );
(router, state) (router, state)
} }