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:
parent
4cf128fcc8
commit
80d13c0292
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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. 如何重启其中一条
|
||||
|
|
|
|||
113
src/notify.rs
113
src/notify.rs
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue