fix(notify): /ready JMAP 探针对齐真实客户端(Basic 认证 + 路径归一)

架构师复核指出两点会致全站不可用的缺陷(notify.rs):

- 探针未带认证:真实 jmap_client 以 .credentials() 取 session,Stalwart
  session 端点需认证;无认证必 401 → /ready 永久 503 → ingress 停止路由。
  改为从 business_runtime 快照取 jmap_username/jmap_password,
  basic_auth 随请求发送(零新增依赖,凭据仅入请求头,绝不日志)。
- session URL 未归一:配置若是裸 origin,GET 根路径必 404 → 误报。
  改为复用 normalize_session_url(client.rs 改为 pub(crate))并请求
  标准 /.well-known/jmap,与 JmapClientBackend::connect_with_runtime 同路径。

顺带:两探针 tokio::join! 并行,/ready 最坏耗时 3s→3s(原 6s)。

门禁 4/4 绿(45 passed, 0 failed, 1 ignored)。
This commit is contained in:
anonsaber 2026-09-28 10:37:22 +08:00
parent c0aab5e966
commit c5bc7b8d82
2 changed files with 25 additions and 13 deletions

View File

@ -384,7 +384,7 @@ fn role_name(role: mailbox::Role) -> Option<String> {
}
}
fn normalize_session_url(value: &str) -> Result<String, JmapError> {
pub(crate) fn normalize_session_url(value: &str) -> Result<String, JmapError> {
let parsed =
url::Url::parse(value).map_err(|_| JmapError::InvalidRequest("JMAP_SESSION_URL"))?;
if parsed.scheme() != "https" {

View File

@ -70,12 +70,14 @@ const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(3_00
/// Read the cached runtime configuration without holding the lock guard across an await.
/// Callers refresh the cache first so this never reports a stale view.
fn cached_jmap_session_url(app: &AppState) -> Option<String> {
app.business_runtime
.read()
.ok()?
.as_ref()
.map(|config| config.jmap_session_url.clone())
fn cached_jmap_session(app: &AppState) -> Option<(String, String, SecretString)> {
app.business_runtime.read().ok()?.as_ref().map(|config| {
(
config.jmap_session_url.clone(),
config.jmap_username.clone(),
config.jmap_password.clone(),
)
})
}
fn cached_bot_token(app: &AppState) -> Option<secrecy::SecretString> {
@ -86,13 +88,21 @@ fn cached_bot_token(app: &AppState) -> Option<secrecy::SecretString> {
.map(|config| config.bot_token.clone())
}
/// End-to-end JMAP session probe: the configured session URL must answer with a 2xx.
/// Reusable by the remote debug surface so readiness and debugging share one implementation.
/// End-to-end JMAP session probe: fetch `/.well-known/jmap` with the configured credentials and
/// require a 2xx. Mirrors `JmapClientBackend::connect_with_runtime` (normalized origin + Basic
/// auth): probing the raw URL unauthenticated would report not-ready forever and make ingress
/// stop routing. Reusable by the remote debug surface so readiness and debugging share one
/// implementation.
async fn probe_jmap_session(app: &AppState) -> Result<(), &'static str> {
let Some(session_url) = cached_jmap_session_url(app) else {
let Some((session_url, username, password)) = cached_jmap_session(app) else {
return Err("jmap session url not configured");
};
let request = reqwest::Client::new().get(session_url).send();
let origin = crate::domain::jmap::client::normalize_session_url(&session_url)
.map_err(|_| "jmap session url invalid")?;
let request = reqwest::Client::new()
.get(format!("{origin}/.well-known/jmap"))
.basic_auth(&username, Some(password.expose_secret()))
.send();
match tokio::time::timeout(PROBE_TIMEOUT, request).await {
Ok(Ok(response)) => response
.error_for_status()
@ -129,8 +139,10 @@ async fn ready(State(app): State<AppState>) -> Response {
let configured = app.setup_missing.is_empty();
let redis = app.state.is_enabled().await.is_ok();
refresh_business_config(&app).await;
let jmap = probe_jmap_session(&app).await.is_ok();
let telegram = probe_telegram_get_me(&app).await.is_ok();
// Probes run in parallel: worst case one PROBE_TIMEOUT instead of two.
let (jmap, telegram) = tokio::join!(probe_jmap_session(&app), probe_telegram_get_me(&app));
let jmap = jmap.is_ok();
let telegram = telegram.is_ok();
if configured && redis && jmap && telegram {
axum::Json(serde_json::json!({
"status": "ready",