diff --git a/.env.example b/.env.example index 8a4f9e8..b3f2728 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,10 @@ CONFIG_ENCRYPTION_KEY=REPLACE_WITH_64_HEX_CHAR_RANDOM_KEY # 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. +# 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; # they are then persisted in Redis and are never echoed by the API. diff --git a/docs/design.md b/docs/design.md index af2ebe1..a74862a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -365,8 +365,8 @@ Testing: **No integrated test directory**, and no stand-alone util tool module. > Use new configuration, keep old instance on failure. This migration replaces the following stage 0 environment variable list. The stage 0 list is only for historical purposes. > Compatibility instructions. -> **Actual implementation** (`ARCH-CONFIG-ENV`): process startup read-only **2** process-level credentials plus listening port (`src/main.rs:45` / `:54` / `:63`, -> Add optional `DEBUG_TOKEN` in `:107`), directly `std::env::var`, **do not use figment, do not use TOML/configuration file**. +> **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**. > **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`. > 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 - `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 }`, 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` (`+`) baked by `build.rs` during compilation. The SPA no longer renders it, so the API is the only way to confirm a deployment landed. +- `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` (`+`) 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. - `POST /api/admin/session` accepts `Authorization: Bearer ` and returns `{ "session": "", "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. - `GET /api/config` and `PUT /api/config` only read and write non-sensitive running parameters: @@ -746,7 +746,7 @@ The LLM capability only exists in the `src/ai.rs` file; **no** configuration/fal | Variable | Type | Default | Purpose | |---|---|---|---| | `LLM_ENABLED` | bool | `false` | Master switch; off by default, no verification when not enabled `LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL` | -| `LLM_ALLOW_NET` | bool | `false` | Runtime outbound permission; `LlmClient` is constructed only if `LLM_ENABLED && LLM_ALLOW_NET` are both true, otherwise the `llm` field is `None` (`src/main.rs:135-141`) | +| `LLM_ALLOW_NET` | bool | `false` | Runtime outbound permission; `LlmClient` is constructed only if `LLM_ENABLED && LLM_ALLOW_NET` are both true, otherwise the `llm` field is `None` (`LlmClient::with_runtime` in `src/main.rs`) | | `LLM_API_KEY` | string | Can be omitted | Bearer token; required only when `LLM_ENABLED=true` | | `LLM_BASE_URL` | URL | Can be omitted | Required only when enabled, and must be `https`, otherwise `AiError::InvalidEndpoint` | | `LLM_MODEL` | string | Can be omitted | Required only when enabled; transparently passed to `model` in `/chat/completions` | diff --git a/docs/design.zh-CN.md b/docs/design.zh-CN.md index 5eab2e1..7cffc09 100644 --- a/docs/design.zh-CN.md +++ b/docs/design.zh-CN.md @@ -365,8 +365,8 @@ message-weave/ > 使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单,阶段0列表仅作为历史 > 兼容说明。 -> **实际实现**(`ARCH-CONFIG-ENV`):进程启动只读 **2 个**进程级凭据加监听端口(`src/main.rs:45` / `:54` / `:63`, -> 另加可选 `DEBUG_TOKEN` 于 `:107`),直接 `std::env::var`,**不使用 figment、不使用 TOML/配置文件**。 +> **实际实现**(`ARCH-CONFIG-ENV`):进程启动只读 **2 个**进程级凭据加监听端口(均经 `main` 里的 `std::env::var`,键名 `REDIS_URL` / `CONFIG_ENCRYPTION_KEY` / `PORT`, +> 另加可选 `DEBUG_TOKEN` 与 `/api/status` 标签 `BACKEND_ID`),**不使用 figment、不使用 TOML/配置文件**。 > **业务配置不再从环境变量读取**:下表字段全部经 Redis 业务配置由 `config` 层以 serde 反序列化装载, > 并经 `validate_business_wire` / `validate_nonblank` 做 fail-closed 校验。 > 历史上存在的 `Config::from_env()` 完整 env 解析路径**已删除**(登记见 `docs/retired.md`), @@ -420,7 +420,7 @@ message-weave/ ### 7.3.1 配置管理 API - `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 }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。`version` 是 `build.rs` 在编译期烘焙的 `BUILD_VERSION`(`+`)。SPA 已不再渲染它,确认部署落地只能查这个接口。 +- `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`(`+`)。SPA 已不再渲染它,确认部署落地只能查这个接口。`backend` 是启动时一次性读取 `BACKEND_ID`(回退到 `HOSTNAME`)的每实例标签;同一镜像构建的实例 `version` 相同,所以 `backend` 才是让穿过负载均衡器的探针分辨命中的是哪个 origin 的字段。`null` 表示未配置标签(两个 env 都未设置);两个都不设的实例在该字段上不可区分,这是设计如此。 - `POST /api/admin/session` 接受 `Authorization: Bearer `,返回 `{ "session": "", "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`)。 - `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数: @@ -746,7 +746,7 @@ LLM 能力只存在于 `src/ai.rs` 一个文件;**没有**配置 / 回退 / | 变量 | 类型 | 默认 | 用途 | |---|---|---|---| | `LLM_ENABLED` | bool | `false` | 总开关;默认关闭,未启用时不校验 `LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL` | -| `LLM_ALLOW_NET` | bool | `false` | 运行时出站许可;`LLM_ENABLED && LLM_ALLOW_NET` 同时为真才构造 `LlmClient`,否则 `llm` 字段为 `None`(`src/main.rs:135-141`) | +| `LLM_ALLOW_NET` | bool | `false` | 运行时出站许可;`LLM_ENABLED && LLM_ALLOW_NET` 同时为真才构造 `LlmClient`,否则 `llm` 字段为 `None`(`LlmClient::with_runtime` 于 `src/main.rs`) | | `LLM_API_KEY` | string | 可省略 | Bearer token;仅 `LLM_ENABLED=true` 时必填 | | `LLM_BASE_URL` | URL | 可省略 | 仅启用时必填,且必须 `https`,否则 `AiError::InvalidEndpoint` | | `LLM_MODEL` | string | 可省略 | 仅启用时必填;透传给 `/chat/completions` 的 `model` | diff --git a/docs/reference.md b/docs/reference.md index 90e390d..c5a25a4 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -275,12 +275,18 @@ Response conventions: The JMAP probe deliberately authenticates against the *normalized origin*: probing the raw session URL unauthenticated would report not-ready forever and make ingress stop routing. - `GET /api/status` (**always 200**) returns - `{"ready": , "mode": "configured"|"configuration-setup", "missing": [...], "version": ""}`. + `{"ready": , "mode": "configured"|"configuration-setup", "missing": [...], "version": "", "backend": ""|null}`. `ready` is `false` and `missing` lists the absent required keys (`REDIS_URL`, `CONFIG_ENCRYPTION_KEY`) when a variable is absent — the route itself never errors, so it is safe to poll. `version` is the `BUILD_VERSION` string baked in by `build.rs` - (`+`). The SPA no longer renders it — the API is now the - only way to confirm a deploy landed. + (`+`) — identical across every instance built from the same + 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 + `HOSTNAME`, which container runtimes set), threaded into `AppState::backend_id` and echoed + here. It is an observability label, not a connection secret and not persisted. `null` means + neither env var was set; two such instances are indistinguishable by this field, so a probe + that needs to tell origins apart must set `BACKEND_ID` per instance at deploy time. 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 config is not loaded or there is no outbound client — `service_unavailable` with `Retry-After: 30`. The three probe endpoints instead report failure diff --git a/docs/reference.zh-CN.md b/docs/reference.zh-CN.md index 1be99ce..0f36cdd 100644 --- a/docs/reference.zh-CN.md +++ b/docs/reference.zh-CN.md @@ -272,11 +272,10 @@ env var — 并且“DEBUG_TOKEN”非空**(“SAF-DEBUG-GATE”;门被读 JMAP 探针有意针对*规范化来源*进行身份验证:探测原始数据 未经身份验证的会话 URL 将永远报告未就绪并使入口停止路由。 - `GET /api/status`(**始终 200**)返回 - `{“ready”:,“mode”:“configured”|“configuration-setup”,“missing”:[...],“version”:“”}`。 - “ready”为“false”,“missing”列出了缺少的必需密钥(“REDIS_URL”、 - `CONFIG_ENCRYPTION_KEY`)当变量不存在时 - 路由本身永远不会出错,所以它是 - 可以安全进行民意调查。 `version` 是由 `build.rs` 烘焙的 `BUILD_VERSION` 字符串 - (`+`)。SPA 已不再渲染它,确认部署落地只能查这个接口。 + `{"ready": , "mode": "configured"|"configuration-setup", "missing": [...], "version": "", "backend": ""|null}`。 + `ready` 为 `false`、`missing` 列出缺少的必需变量名(`REDIS_URL`、 + `CONFIG_ENCRYPTION_KEY`)——路由本身永不报错,可安全轮询。`version` 是 `build.rs` 烘焙的 `BUILD_VERSION` 字符串 + (`+`)——同一镜像构建的每个实例都相同,因此它回答「本次部署是否落地」,但不回答「命中的是哪个实例」。`backend` 回答后者:每实例标签,启动时一次性读取 `BACKEND_ID`(回退容器运行时设置的 `HOSTNAME`),穿入 `AppState::backend_id` 并在此回显。它是观测标签,不是连接密钥,也不持久化。`null` 表示两个 env 都未设置;两个都不设的实例在该字段上不可区分,所以需要分辨 origin 的探针必须在部署时为每个实例设置 `BACKEND_ID`。SPA 已不再渲染 `version`,确认部署落地只能查这个接口。 - `/debug/*` 在一个地方返回 **503**:业务执行时`POST /debug/notify` 配置未加载或没有出站客户端 — `service_unavailable` `重试时间:30`。三个探测端点反而报告失败 diff --git a/src/debug.rs b/src/debug.rs index ca0cf92..68a6be6 100644 --- a/src/debug.rs +++ b/src/debug.rs @@ -313,6 +313,7 @@ mod tests { state, HashSet::new(), Arc::new(NoopWorker), + None, runtime_provider(OutboundConfig::default()), SecretString::new(String::new()), debug_token, diff --git a/src/main.rs b/src/main.rs index de86aa8..cf44d52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,23 @@ 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 { + 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::domain::jmap::{client::JmapClientBackend, JmapService}; use config::{encryption_key_from_env, Config}; @@ -50,6 +67,7 @@ async fn main() -> Result<(), error::BotError> { .parse() .map_err(|e| error::BotError::Config(format!("invalid PORT: {e}")))?; let listener = tokio::net::TcpListener::bind(addr).await?; + let backend_id = backend_id_from_env(); let mut missing = Vec::new(); let redis_value = match std::env::var("REDIS_URL") { Ok(value) if !value.trim().is_empty() => value, @@ -73,7 +91,7 @@ async fn main() -> Result<(), error::BotError> { ?missing, "required bootstrap environment is incomplete; serving setup mode" ); - let app = router_configuration_setup(missing).merge(web::router()); + let app = router_configuration_setup(missing, backend_id).merge(web::router()); axum::serve(listener, app).await?; return Ok(()); } @@ -198,6 +216,7 @@ async fn main() -> Result<(), error::BotError> { redis, allowlist, Arc::new(WorkerHandle::new(worker)), + backend_id, runtime, admin_token, debug_token, diff --git a/src/notify.rs b/src/notify.rs index 618bb2c..44ff4ab 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -104,6 +104,11 @@ pub(crate) struct AppState { /// `--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. pub(crate) debug_token: Option, + /// 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, } impl From for AuthState { @@ -220,6 +225,7 @@ async fn setup_status(State(app): State) -> Response { "mode": if ready { "configured" } else { "configuration-setup" }, "missing": app.setup_missing.as_ref(), "version": BUILD_VERSION, + "backend": &app.backend_id, })) .into_response() } @@ -1924,6 +1930,7 @@ pub fn router_with_state( state, allowlist, Arc::new(NoopWorker), + None, ) } @@ -1939,7 +1946,7 @@ pub fn router_with_state( /// 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 /// `ready === true`), so nothing the status page renders is lost. -pub fn router_configuration_setup(missing: Vec) -> Router { +pub fn router_configuration_setup(missing: Vec, backend_id: Option) -> Router { let worker_handle = Arc::new(WorkerHandle::new(Arc::new(NoopWorker))); let app_state = AppState { auth: AuthState { @@ -1958,6 +1965,7 @@ pub fn router_configuration_setup(missing: Vec) -> Router { business_revision: Arc::new(RwLock::new(0)), setup_missing: Arc::new(missing), reload: Arc::new(ReloadCoordinator::new(worker_handle, None)), + backend_id, }; Router::new() .route("/api/status", get(setup_status)) @@ -1972,6 +1980,7 @@ pub fn router_with_worker_state( state: S, allowlist: HashSet, worker_handler: Arc, + backend_id: Option, ) -> Router { router_with_worker_state_and_runtime( secrets, @@ -1979,6 +1988,7 @@ pub fn router_with_worker_state( state, allowlist, worker_handler, + backend_id, runtime_provider(OutboundConfig::default()), ) } @@ -1989,6 +1999,7 @@ pub fn router_with_worker_state_and_runtime( state: S, allowlist: HashSet, worker_handler: Arc, + backend_id: Option, runtime: RuntimeConfigProvider, ) -> Router { router_with_worker_state_runtime_bootstrap( @@ -1997,6 +2008,7 @@ pub fn router_with_worker_state_and_runtime( state, allowlist, worker_handler, + backend_id, runtime, SecretString::new(String::new()), None, @@ -2013,6 +2025,7 @@ pub fn router_with_worker_state_runtime_bootstrap( state: S, allowlist: HashSet, worker_handler: Arc, + backend_id: Option, runtime: RuntimeConfigProvider, admin_token: SecretString, debug_token: Option, @@ -2023,6 +2036,7 @@ pub fn router_with_worker_state_runtime_bootstrap( state, allowlist, worker_handler, + backend_id, runtime, admin_token, debug_token, @@ -2040,6 +2054,7 @@ fn router_with_worker_state_runtime_bootstrap_config state: S, allowlist: HashSet, worker_handler: Arc, + backend_id: Option, runtime: RuntimeConfigProvider, admin_token: SecretString, debug_token: Option, @@ -2061,6 +2076,7 @@ fn router_with_worker_state_runtime_bootstrap_config business_revision: Arc::new(RwLock::new(0)), setup_missing: Arc::new(setup_missing), reload, + backend_id, }; let mut router = Router::new() .route("/webhook/tg", post(telegram_webhook)) @@ -2211,7 +2227,7 @@ mod tests { #[tokio::test] async fn configuration_setup_status_lists_only_missing_names() { - let response = router_configuration_setup(vec!["REDIS_URL".into()]) + let response = router_configuration_setup(vec!["REDIS_URL".into()], None) .oneshot( Request::builder() .method("GET") @@ -2231,19 +2247,45 @@ mod tests { assert!(!body.windows(4).any(|part| part == b"pass")); } + #[tokio::test] + async fn setup_status_reports_backend_label() { + // `backend` is threaded from the router builder into `/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. `main` reads `BACKEND_ID`/`HOSTNAME` at + // startup and passes a real value; here the value is echoed verbatim end-to-end. + let response = router_configuration_setup(vec![], Some("edge-1".into())) + .oneshot( + Request::builder() + .method("GET") + .uri("/api/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + 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_eq!(value["backend"], "edge-1"); + } + #[tokio::test] async fn configuration_setup_status_lists_both_bootstrap_requirements() { - let response = - router_configuration_setup(vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()]) - .oneshot( - Request::builder() - .method("GET") - .uri("/api/status") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let response = router_configuration_setup( + vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()], + None, + ) + .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(); @@ -2255,8 +2297,10 @@ mod tests { #[tokio::test] async fn configuration_setup_mounts_only_status_and_probes() { - let router = - router_configuration_setup(vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()]); + let router = router_configuration_setup( + vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()], + None, + ); let token = "Bearer CONFIG_ENCRYPTION_KEY-candidate"; // The status surface and both probes stay reachable: the SPA bootstrap card is driven @@ -2312,6 +2356,7 @@ mod tests { MemoryState::default(), HashSet::new(), Arc::new(NoopWorker), + None, runtime_provider(OutboundConfig::default()), SecretString::new("acl-root".into()), None, @@ -3297,6 +3342,7 @@ mod tests { state.clone(), HashSet::new(), handler, + None, ); (router, state) }