From 80d13c0292bdf1321a9165c9ea60f17b821d0879 Mon Sep 17 00:00:00 2001 From: anonsaber Date: Tue, 29 Sep 2026 15:26:54 +0800 Subject: [PATCH] fix(notify): stop exposing the business and admin routes in configuration-setup mode The setup router delegated to the full factory, so all 12 business and admin routes were registered even though not one could ever work: admin_token is an empty SecretString and constant_time_eq has a !expected.is_empty() guard, so an empty expected value rejects every candidate forever. MemoryState could not persist a bootstrap write either. Those routes answered 401 {"error":"unauthorized"}, which is indistinguishable from a mistyped credential. An operator or a probe reads that as "try a different token" when the truth is "these endpoints do not exist in this mode". router_configuration_setup now builds its own AppState and mounts only /api/status, /ready and /healthz. Verified live with no env vars: healthz 200, ready 503, /api/status 200 with the missing list, and all 12 business/admin routes 404. The SPA is unaffected: /api/status is the only endpoint it calls at startup, and web/config.js reveals the admin-session card only when ready === true. Added configuration_setup_mounts_only_status_and_probes to guard the route table, and configuration_setup_call as its helper. Docs: reference.md section 3 and 5.1 now describe the reduced surface and cite the empty-expected-value guard; deployment.md's probe table had /api/status listed as 503 {"status":...} when it returns 200 with a "mode" field; retired.md section 4.1 records the removed route registrations. Anchors refreshed for the +19 lines in notify.rs. --- docs/deployment.md | 4 +- docs/reference.md | 20 +++++--- docs/retired.md | 17 +++++++ src/notify.rs | 113 +++++++++++++++++++++++++++++++++++++++------ 4 files changed, 131 insertions(+), 23 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 9838de0..d0461bf 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -74,7 +74,7 @@ `hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在 `src/main.rs:217-264`。 -缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1321-1322`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。 +缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1340-1342`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。 **鉴权** @@ -494,7 +494,7 @@ docker run --env-file .env -p 8080:8080 messageweave:latest |---|---|---| | `GET /healthz` | 无条件 200 | 存活探测(进程还活着) | | `GET /ready` | 配置、Redis 或上游探针失败时 503 | 就绪探测、负载均衡摘除、Uptime Kuma | -| `GET /api/status` | 缺必需环境变量时 503 + `{"status":"configuration-setup","missing":[...]}` | 排查"起来了但没干活" | +| `GET /api/status` | 恒 200;返回 `{"ready":bool,"mode":"configured"\|"configuration-setup","missing":[...]}`,只列缺少的环境变量名称 | 排查"起来了但没干活" | > **由平台 ingress 探测 `/ready`。**镜像现已内置 `curl`(见 Dockerfile `runtime` 阶段), > 所以在 Dockerfile 部署路径下容器内写 `HEALTHCHECK` 在技术上可行(HostStack 的 `runtime: rust` diff --git a/docs/reference.md b/docs/reference.md index 2345db9..f31d4da 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -200,10 +200,12 @@ string `"30"`: ## 3. Backend routes All HTTP API routes are registered in `router_with_worker_state_runtime_bootstrap_config` -(`notify.rs:1276`), with the routes wired at `notify.rs:1305-1319`. The three static SPA routes +(`notify.rs:1295`), with the routes wired at `notify.rs:1323-1338`. The three static SPA routes in the last table row are the exception: they live in `web::router()` (`src/web.rs:19-24`) and are merged into the router via `.merge(web::router())`: the setup-mode router at `src/main.rs:76`, -the production router at `src/main.rs:180`. +the production router at `src/main.rs:180`. The setup-mode router at `src/main.rs:76` mounts only +three of this table — `/healthz`, `/ready` and `/api/status` — and no business or admin route; +§5.1 explains why that is deliberate. | Method | Path | Handler | |---|---|---| @@ -227,7 +229,7 @@ the production router at `src/main.rs:180`. The remote-debug surface (`src/debug.rs:72-80`) is merged **only when the debug surface is requested — `--debug` on the command line or a truthy `DEBUG_ENABLED` env var — and `DEBUG_TOKEN` is non-empty** (`SAF-DEBUG-GATE`, gate logic `src/main.rs:15-25`, wired at `src/main.rs:102-114`, -`src/notify.rs:1321-1322`); otherwise none of these routes exist and requests fall through to +`src/notify.rs:1340-1342`); otherwise none of these routes exist and requests fall through to axum's generic `404`, not a 401. All seven require `Authorization: Bearer `, checked by `debug_authorized` (`src/debug.rs:45`), which delegates to the production `worker_authorized` so the comparison is constant time (`src/notify.rs:1136`). @@ -343,10 +345,14 @@ and reconcile traffic share one router and reconcile is a standalone `POST /reco endpoint, so the variable never changed any runtime behaviour — there is now nothing to set. **Missing a required variable does not crash the process.** It logs a warning and serves -`router_configuration_setup`, which builds a router over `MemoryState` with empty tokens and -a `NoopWorker` (worker.rs:143-145). The container stays up and answers requests while doing -no business work. This is a deliberate fail-closed-to-setup posture, and it is the single -most likely cause of "the container is healthy but nothing happens". +`router_configuration_setup` (`notify.rs:1200-1223`), which mounts only `/api/status`, +`/ready` and `/healthz` on top of the static SPA. The business and admin routes from §3 are not +registered at all: `admin_token` is empty, so `constant_time_eq` (`notify.rs:1136`) would reject +every candidate forever, and `MemoryState` (`state.rs:875`) could not persist a bootstrap +write anyway. Posting to `/api/bootstrap` in this mode yields `404` (route absent), not a `401` +that reads as "retry with a better credential". The container stays up and answers the status +surface while doing no business work. This is a deliberate fail-closed-to-setup posture, and it +is the single most likely cause of "the container is healthy but nothing happens". ### 5.2 Redis-resident business configuration diff --git a/docs/retired.md b/docs/retired.md index 62552ca..ebb4fec 100644 --- a/docs/retired.md +++ b/docs/retired.md @@ -133,6 +133,23 @@ > (Redis 运行参数 `max_retries`,回落默认见 `docs/reference.md` §6.1),后者是常量 > (两个构造器内硬编码 300,未进入业务配置 wire)。 +### 4.1 引导模式挂载的业务/管理路由 + +`router_configuration_setup` 原先直接委托全量工厂 `router_with_worker_state_runtime_bootstrap_config`, +因此 12 条业务与管理路由在引导模式下全部被注册,却**一条也走不通**:引导模式的 `admin_token` 是空值, +而 `constant_time_eq` 带 `!expected.is_empty()` 守卫,空的期望值会永久拒绝任何候选凭据;即便鉴权通过, +`MemoryState` 也无法持久化一次 bootstrap 写入。 + +| 条目 | 类型 | 删除原因 | 现状 | +|---|---|---|---| +| `/api/bootstrap`、`/api/admin/session`、`/api/admin/session/revoke`、`/api/config`、`/api/enabled`、`/api/business-config`、`/webhook/tg`、`/push/jmap`、`/api/push/register`、`/api/push/disable`、`/reconcile`、`/worker` | 已删除路由注册 | 永久 401 的路由读起来像"凭据写错了" | 引导模式自建 `AppState`,只挂 `/api/status`、`/ready`、`/healthz`;上述路径返回 `404` | + +`401` 与 `404` 的差别不是洁癖:`{"error":"unauthorized"}` 与"凭据填错"完全同形,操作员和探针会把 +"这些端点在此模式下根本不存在"误读成"再换个凭据试试"。现在两者可区分——`/api/status` 仍返回 +`{"missing":[...],"mode":"configuration-setup","ready":false}`(HTTP 200),业务与管理面直接 404。 +回归由 `configuration_setup_mounts_only_status_and_probes` 守护:逐一断言 12 条路径 404,并断言 +`/healthz` 200、`/api/status` 200、`/ready` 503。 + --- ## 5. 如何重启其中一条 diff --git a/src/notify.rs b/src/notify.rs index 04114f2..b80f358 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -1185,24 +1185,43 @@ pub fn router_with_state( ) } -/// Configuration-only listener used when bootstrap credentials are absent. MemoryState is -/// deliberately not advertised as persistence: all business writes remain unavailable until -/// Redis is configured. +/// Configuration-only listener used when bootstrap credentials are absent. +/// +/// It deliberately mounts only the configuration-status surface and the two probes +/// (`/api/status`, `/ready`, `/healthz`). The full listener's business and admin routes are +/// absent on purpose rather than present-but-denied: setup mode has no Redis and no +/// `CONFIG_ENCRYPTION_KEY`, so `admin_token` is empty and `constant_time_eq` refuses an empty +/// expected value — every admin route would return 401 forever, and `MemoryState` could not +/// persist a bootstrap write anyway. Advertising those routes with 401 read as "retry with a +/// better credential" when the truth is "these endpoints do not exist in this mode"; returning +/// 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 { - router_with_worker_state_runtime_bootstrap_config( - AuthSecrets { + let worker_handle = Arc::new(WorkerHandle::new(Arc::new(NoopWorker))); + let app_state = AppState { + auth: AuthState { reconcile_token: SecretString::new(String::new()), telegram_webhook_secret: SecretString::new(String::new()), }, - SecretString::new(String::new()), - MemoryState::default(), - HashSet::new(), - Arc::new(NoopWorker), - runtime_provider(OutboundConfig::default()), - SecretString::new(String::new()), - None, - missing, - ) + worker_token: SecretString::new(String::new()), + state: Arc::new(MemoryState::default()), + allowlist: Arc::new(HashSet::new()), + worker: worker_handle.clone(), + runtime: runtime_provider(OutboundConfig::default()), + admin_token: SecretString::new(String::new()), + debug_token: None, + business_config: Arc::new(RwLock::new(None)), + business_runtime: Arc::new(RwLock::new(None)), + business_revision: Arc::new(RwLock::new(0)), + setup_missing: Arc::new(missing), + reload: Arc::new(ReloadCoordinator::new(worker_handle, None)), + }; + Router::new() + .route("/api/status", get(setup_status)) + .route("/ready", get(ready)) + .route("/healthz", get(healthz)) + .with_state(app_state) } pub fn router_with_worker_state( @@ -1347,6 +1366,26 @@ mod tests { test_router().oneshot(request).await.unwrap().status() } + async fn configuration_setup_call( + router: Router, + method: &str, + uri: &str, + authorization: &str, + ) -> StatusCode { + router + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header("authorization", authorization) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + .status() + } + #[tokio::test] async fn reconcile_requires_bearer_token() { assert_eq!( @@ -1458,6 +1497,52 @@ mod tests { assert_eq!(value["missing"][1], "CONFIG_ENCRYPTION_KEY"); } + #[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 token = "Bearer CONFIG_ENCRYPTION_KEY-candidate"; + + // The status surface and both probes stay reachable: the SPA bootstrap card is driven + // by /api/status, and liveness/readiness still have to answer. + assert_eq!( + configuration_setup_call(router.clone(), "GET", "/healthz", token).await, + StatusCode::OK + ); + assert_eq!( + configuration_setup_call(router.clone(), "GET", "/api/status", token).await, + StatusCode::OK + ); + assert_eq!( + configuration_setup_call(router.clone(), "GET", "/ready", token).await, + StatusCode::SERVICE_UNAVAILABLE + ); + + // The business and admin surface is absent rather than present-but-denied. An empty + // CONFIG_ENCRYPTION_KEY makes constant_time_eq reject every candidate forever, so 401 + // here read as "retry the credential" when the truth was "route does not exist". + for (method, uri) in [ + ("GET", "/api/config"), + ("GET", "/api/enabled"), + ("POST", "/api/business-config"), + ("POST", "/api/bootstrap"), + ("POST", "/api/admin/session"), + ("POST", "/api/admin/session/revoke"), + ("POST", "/webhook/tg"), + ("POST", "/push/jmap"), + ("POST", "/api/push/register"), + ("POST", "/api/push/disable"), + ("POST", "/reconcile"), + ("POST", "/worker"), + ] { + assert_eq!( + configuration_setup_call(router.clone(), method, uri, token).await, + StatusCode::NOT_FOUND, + "{method} {uri} must be absent in configuration-setup mode" + ); + } + } + #[tokio::test] async fn failed_bootstrap_does_not_consume_initialization_slot() { let app = router_with_worker_state_runtime_bootstrap(