diff --git a/README.md b/README.md index 8364a09..dcbfb95 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ AI 摘要默认关闭。Telegram 用户可发送 `/ai on`(1小时)、`临时一次`、`1小时`、`今天`、`7天` 或 `直到我撤销` 明确授权,再发送 `/summary `;授权到期不会自动续期,`/ai off` 立即撤销。未授权时仅返回邮件元数据,LLM 失败则回退为本地截断摘要。 -> Redis 未初始化时服务进入 configuration-only 模式,仅提供 SPA、bootstrap 和探针。 +> 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;状态页会提示缺少的环境变量,不回显任何密钥,也不会伪造 Redis 持久化成功。 --- diff --git a/docs/deployment.md b/docs/deployment.md index 22f0ed5..4d6725d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -105,7 +105,7 @@ AI 授权期限由用户选择(临时一次、1小时、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。 | `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 | -> **三个入口鉴权变量均为必填(fail-closed,`SAF-AUTH-*`)**:`RECONCILE_TOKEN` / `TG_WEBHOOK_SECRET` / `JMAP_PUSH_VERIFICATION` 缺失即**启动失败**(`Config::from_env()` 报缺失),不存在"未配置则放行"的降级路径。三个入口分别校验:`/reconcile`(Bearer 头)、`/webhook/tg`(secret 头)、`/push/jmap`(Body `verificationCode`);校验失败一律返回 `401`,且在鉴权通过前**不产生任何副作用**。`/healthz` 与 `/ready` 为公开探针,不含敏感信息。 +> 业务鉴权变量由 Redis `config:business` 管理,不再要求作为启动环境变量。缺少 `REDIS_URL` 或非法/缺失 `CONFIG_ENCRYPTION_KEY` 时服务仍监听并提供 SPA、`/api/status` 与探针,状态为 `configuration-setup`,不会伪造持久化成功;配置恢复后再启用业务入口鉴权。 > 示例占位见仓库根目录 [`.env.example`](../.env.example)(仅占位符,**严禁**放入真实密钥)。 > **配置管理页面**:服务根路径 `/` 提供嵌入 Rust 二进制的 SPA。输入 Redis URL 的 ACL 密码后,`POST /api/admin/session` 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 `/api/config` 读取和保存;完整业务配置通过 `PUT /api/business-config` 替换并热加载。业务配置 API 不提供 GET,密钥不会回显;每次完整替换都需重新输入必填密钥。配置保存在外部 Redis(`C-REDIS-ONLY-STATE`),静态资源编译时随二进制打包,无运行期本地文件。 diff --git a/src/main.rs b/src/main.rs index 8f7a8b9..81eaa10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,8 @@ use crate::channel::telegram::TelegramClient; use crate::domain::jmap::{client::JmapClientBackend, JmapService}; use config::{encryption_key_from_env, Config}; use notify::{ - router_with_worker_state_runtime_bootstrap, MetadataWorker, WorkerHandle, WorkerHandler, + router_configuration_setup, router_with_worker_state_runtime_bootstrap, MetadataWorker, + WorkerHandle, WorkerHandler, }; use state::{runtime_provider, RedisState}; use std::sync::Arc; @@ -23,17 +24,41 @@ use std::sync::Arc; #[tokio::main] async fn main() -> Result<(), error::BotError> { tracing_subscriber::fmt().with_env_filter("info").init(); + let port = std::env::var("PORT") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(8080_u16); + let addr: SocketAddr = format!("0.0.0.0:{port}") + .parse() + .map_err(|e| error::BotError::Config(format!("invalid PORT: {e}")))?; + let listener = tokio::net::TcpListener::bind(addr).await?; + let redis_value = match std::env::var("REDIS_URL") { + Ok(value) if !value.trim().is_empty() => value, + _ => { + tracing::warn!("REDIS_URL is not configured; serving configuration setup mode"); + let app = router_configuration_setup(vec!["REDIS_URL".into()]).merge(web::router()); + axum::serve(listener, app).await?; + return Ok(()); + } + }; + let encryption_key = match encryption_key_from_env() { + Ok(key) => key, + Err(_) => { + tracing::warn!( + "CONFIG_ENCRYPTION_KEY is not configured or invalid; serving setup mode" + ); + let app = router_configuration_setup(vec!["CONFIG_ENCRYPTION_KEY".into()]) + .merge(web::router()); + axum::serve(listener, app).await?; + return Ok(()); + } + }; // The Redis URL is the only bootstrap credential in the migration path. Until // the bootstrap route is wired, retain the legacy parser solely to obtain it. let bootstrap_config = match Config::from_env() { Ok(config) => config, - Err(_) => { - let redis_url = std::env::var("REDIS_URL") - .map_err(|_| error::BotError::Config("missing REDIS_URL".into()))?; - Config::redis_only(secrecy::SecretString::new(redis_url)) - } + Err(_) => Config::redis_only(secrecy::SecretString::new(redis_value.clone())), }; - let encryption_key = encryption_key_from_env()?; let redis_url = bootstrap_config.redis_url.clone(); // RUN_MODE is intentionally explicit even while both modes share the stage-0 // no-op router; later stages attach webhook/reconcile side effects here. @@ -46,14 +71,10 @@ async fn main() -> Result<(), error::BotError> { ))) } } - let addr: SocketAddr = format!("0.0.0.0:{}", bootstrap_config.port) - .parse() - .map_err(|e| error::BotError::Config(format!("invalid PORT: {e}")))?; tracing::info!( port = bootstrap_config.port, "starting webhook HTTP entrypoint" ); - let listener = tokio::net::TcpListener::bind(addr).await?; let redis = RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key)) .await?; diff --git a/src/notify.rs b/src/notify.rs index 4dc9059..268136b 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -20,6 +20,7 @@ use crate::config::{ session_digest, validate_business_wire, AuthSecrets, BusinessConfig, BusinessConfigWire, }; use crate::domain::jmap::{client::JmapClientBackend, JmapService}; +use crate::state::MemoryState; use crate::state::{runtime_provider, OutboundConfig, ReliableState, RuntimeConfigProvider}; pub use crate::worker::{ MetadataWorker, NoopWorker, ReloadCoordinator, WorkerHandle, WorkerHandler, @@ -44,6 +45,7 @@ struct AppState { business_config: Arc>>, business_runtime: Arc>>, business_revision: Arc>, + setup_missing: Arc>, reload: Arc, } @@ -65,6 +67,16 @@ async fn ready() -> impl IntoResponse { (StatusCode::OK, "ready") } +async fn setup_status(State(app): State) -> Response { + let ready = app.setup_missing.is_empty(); + axum::Json(serde_json::json!({ + "ready": ready, + "mode": if ready { "configured" } else { "configuration-setup" }, + "missing": app.setup_missing.as_ref(), + })) + .into_response() +} + async fn telegram_webhook( State(app): State, headers: HeaderMap, @@ -619,6 +631,26 @@ 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. +pub fn router_configuration_setup(missing: Vec) -> Router { + router_with_worker_state_runtime_bootstrap_config( + AuthSecrets { + reconcile_token: SecretString::new(String::new()), + telegram_webhook_secret: SecretString::new(String::new()), + jmap_push_verification: SecretString::new(String::new()), + }, + SecretString::new(String::new()), + MemoryState::default(), + HashSet::new(), + Arc::new(NoopWorker), + runtime_provider(OutboundConfig::default()), + SecretString::new(String::new()), + missing, + ) +} + pub fn router_with_worker_state( secrets: AuthSecrets, worker_token: SecretString, @@ -672,9 +704,14 @@ pub fn router_with_worker_state_runtime_bootstrap( worker_handler, runtime, bootstrap_token, + Vec::new(), ) } +#[expect( + clippy::too_many_arguments, + reason = "router wiring keeps state dependencies explicit" +)] fn router_with_worker_state_runtime_bootstrap_config( secrets: AuthSecrets, worker_token: SecretString, @@ -683,6 +720,7 @@ fn router_with_worker_state_runtime_bootstrap_config worker_handler: Arc, runtime: RuntimeConfigProvider, bootstrap_token: SecretString, + setup_missing: Vec, ) -> Router { let worker_handle = Arc::new(WorkerHandle::new(worker_handler)); let reload = Arc::new(ReloadCoordinator::new(worker_handle.clone(), None)); @@ -698,6 +736,7 @@ fn router_with_worker_state_runtime_bootstrap_config .route("/api/admin/session", post(create_admin_session)) .route("/healthz", get(healthz)) .route("/ready", get(ready)) + .route("/api/status", get(setup_status)) .with_state(AppState { auth: AuthState::from(secrets), worker_token, @@ -709,6 +748,7 @@ fn router_with_worker_state_runtime_bootstrap_config 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(setup_missing), reload, }) } @@ -804,6 +844,28 @@ mod tests { ); } + #[tokio::test] + async fn configuration_setup_status_lists_only_missing_names() { + let response = router_configuration_setup(vec!["REDIS_URL".into()]) + .oneshot( + Request::builder() + .method("GET") + .uri("/api/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["mode"], "configuration-setup"); + assert_eq!(value["missing"][0], "REDIS_URL"); + assert!(!body.windows(4).any(|part| part == b"pass")); + } + #[tokio::test] async fn failed_bootstrap_does_not_consume_initialization_slot() { let app = router_with_worker_state_runtime_bootstrap( diff --git a/web/config.js b/web/config.js index 94b69fd..caae5ff 100644 --- a/web/config.js +++ b/web/config.js @@ -121,6 +121,18 @@ return parseResponseText(responseText, response.status); } + async function showSetupStatus() { + try { + const status = await request("/api/status", "GET", undefined, ""); + if (status && status.ready === false) { + const missing = Array.isArray(status.missing) ? status.missing.join("、") : "启动配置"; + showNotice("error", `服务处于配置引导模式。请在部署环境设置:${missing};密钥不会在页面保存或回显。`); + } + } catch { + // The regular connection flow reports unavailable services without exposing details. + } + } + function showConfigView() { authCard.hidden = true; configPanel.hidden = false; @@ -568,6 +580,7 @@ }); showRuntimePreview(); + showSetupStatus(); updateLLMRequirements(); updateButtons(); })();