From 800d6e0d0017a8ff05a9a2c68b09fac8704e3e4e Mon Sep 17 00:00:00 2001 From: anonsaber Date: Mon, 28 Sep 2026 12:43:33 +0800 Subject: [PATCH] =?UTF-8?q?notify:=20SPA=20=E7=AE=A1=E7=90=86=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E6=94=B9=E7=94=A8=20CONFIG=5FENCRYPTION=5FKEY?= =?UTF-8?q?=EF=BC=8C=E8=A7=A3=E9=99=A4=E4=B8=8E=20Redis=20ACL=20=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E8=80=A6=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原实现把 SPA 管理凭据派生自 REDIS_URL 的 ACL 密码,并在 handler 侧用 `.is_empty()` 直接拒绝空凭据。托管 Redis 常见配置是 TLS-only 无密码, 那种情况下 `.is_empty()` 恒为真,管理界面永久 401 —— `POST /api/bootstrap` 与 `POST /api/admin/session` 双双不可用,无法完成初始化。 改为直接取 CONFIG_ENCRYPTION_KEY:它本就是启动必填 Secret(64 位 hex / 32 字节,见 config::encryption_key_from_env),启动时已校验,因此生产环境 不可能出现空凭据,handler 侧的空串守卫随之失效,一并移除。Redis ACL 密码 回归本职:只用于数据库连接。 - main.rs:env 只读一次(encryption_key_raw),admin_token 与已校验值不可能 分叉;删除 Url::parse(redis_url).password() 分支。 - notify.rs:AppState 字段 bootstrap_token → admin_token(4 个构造点同步), 两处 handler 删除空串守卫,bootstrap() 文档不再称 Redis ACL bootstrap。 - 测试更名 bootstrap_rejects_missing_admin_credential:断言语义不变(测试态 空凭据收到 Bearer 仍 401),名称改为反映新语义。 门禁全绿:cargo fmt --check / check --locked / clippy --all-targets -D warnings / test --locked = 45 passed, 0 failed, 1 ignored。 --- src/main.rs | 15 +++++++++------ src/notify.rs | 24 ++++++++++-------------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/main.rs b/src/main.rs index 00241f6..73323b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,9 @@ async fn main() -> Result<(), error::BotError> { String::new() } }; + // The SPA admin credential is this same Secret (see below): capture it once so the + // validated value and the admin token can never diverge. + let encryption_key_raw = std::env::var("CONFIG_ENCRYPTION_KEY").unwrap_or_default(); let encryption_key = match encryption_key_from_env() { Ok(key) => key, Err(_) => { @@ -84,11 +87,11 @@ async fn main() -> Result<(), error::BotError> { let redis = RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key)) .await?; - let bootstrap_token = url::Url::parse(redis_url.expose_secret()) - .ok() - .and_then(|value| value.password().map(ToOwned::to_owned)) - .map(secrecy::SecretString::new) - .unwrap_or_else(|| secrecy::SecretString::new(String::new())); + // SPA admin credential == CONFIG_ENCRYPTION_KEY (validated as a required + // startup Secret above). It must not be derived from REDIS_URL: a Redis ACL + // password only gates the database connection, and passwordless managed + // Redis would leave the admin UI permanently locked out. + let admin_token = secrecy::SecretString::new(encryption_key_raw); let worker_state: std::sync::Arc = std::sync::Arc::new( RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key)) .await?, @@ -172,7 +175,7 @@ async fn main() -> Result<(), error::BotError> { allowlist, Arc::new(WorkerHandle::new(worker)), runtime, - bootstrap_token, + admin_token, ) .merge(web::router()); axum::serve(listener, app).await?; diff --git a/src/notify.rs b/src/notify.rs index 68d0210..56b5dea 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -43,7 +43,7 @@ struct AppState { allowlist: Arc>, worker: Arc, runtime: RuntimeConfigProvider, - bootstrap_token: SecretString, + admin_token: SecretString, business_config: Arc>>, business_runtime: Arc>>, business_revision: Arc>, @@ -595,12 +595,10 @@ async fn put_business_config( StatusCode::NO_CONTENT.into_response() } -/// One-shot Redis ACL bootstrap. The ACL password is only compared in constant time and is -/// never included in the response; SET-NX in ReliableState closes the initialization race. +/// One-shot business-config bootstrap. The admin credential is only compared in constant time +/// and is never included in the response; SET-NX in ReliableState closes the init race. async fn bootstrap(State(app): State, headers: HeaderMap, body: Bytes) -> Response { - if app.bootstrap_token.expose_secret().is_empty() - || !worker_authorized(&headers, app.bootstrap_token.expose_secret()) - { + if !worker_authorized(&headers, app.admin_token.expose_secret()) { return error_response(StatusCode::UNAUTHORIZED, "unauthorized", false); } let Ok(config) = serde_json::from_slice::(&body) else { @@ -759,9 +757,7 @@ async fn revoke_admin_session(State(app): State, headers: HeaderMap) - } async fn create_admin_session(State(app): State, headers: HeaderMap) -> Response { - if app.bootstrap_token.expose_secret().is_empty() - || !worker_authorized(&headers, app.bootstrap_token.expose_secret()) - { + if !worker_authorized(&headers, app.admin_token.expose_secret()) { return error_response(StatusCode::UNAUTHORIZED, "unauthorized", false); } let mut bytes = [0_u8; 32]; @@ -1229,7 +1225,7 @@ pub fn router_with_worker_state_runtime_bootstrap( allowlist: HashSet, worker_handler: Arc, runtime: RuntimeConfigProvider, - bootstrap_token: SecretString, + admin_token: SecretString, ) -> Router { router_with_worker_state_runtime_bootstrap_config( secrets, @@ -1238,7 +1234,7 @@ pub fn router_with_worker_state_runtime_bootstrap( allowlist, worker_handler, runtime, - bootstrap_token, + admin_token, Vec::new(), ) } @@ -1254,7 +1250,7 @@ fn router_with_worker_state_runtime_bootstrap_config allowlist: HashSet, worker_handler: Arc, runtime: RuntimeConfigProvider, - bootstrap_token: SecretString, + admin_token: SecretString, setup_missing: Vec, ) -> Router { let worker_handle = Arc::new(WorkerHandle::new(worker_handler)); @@ -1282,7 +1278,7 @@ fn router_with_worker_state_runtime_bootstrap_config allowlist: Arc::new(allowlist), worker: worker_handle, runtime, - bootstrap_token, + admin_token, business_config: Arc::new(RwLock::new(None)), business_runtime: Arc::new(RwLock::new(None)), business_revision: Arc::new(RwLock::new(0)), @@ -1366,7 +1362,7 @@ mod tests { } #[tokio::test] - async fn bootstrap_rejects_without_acl_bootstrap_credential() { + async fn bootstrap_rejects_missing_admin_credential() { assert_eq!( request( Request::builder()