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.
This commit is contained in:
anonsaber 2026-09-29 15:26:54 +08:00
parent 4cf128fcc8
commit 80d13c0292
4 changed files with 131 additions and 23 deletions

View File

@ -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`

View File

@ -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 <DEBUG_TOKEN>`,
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

View File

@ -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. 如何重启其中一条

View File

@ -1185,24 +1185,43 @@ pub fn router_with_state<S: ReliableState + 'static>(
)
}
/// 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<String>) -> 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<S: ReliableState + 'static>(
@ -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(