feat: provide configuration setup mode
This commit is contained in:
parent
fc43c6bf93
commit
e5ec3aef4c
|
|
@ -49,7 +49,7 @@
|
|||
|
||||
AI 摘要默认关闭。Telegram 用户可发送 `/ai on`(1小时)、`临时一次`、`1小时`、`今天`、`7天` 或 `直到我撤销` 明确授权,再发送 `/summary <email_id>`;授权到期不会自动续期,`/ai off` 立即撤销。未授权时仅返回邮件元数据,LLM 失败则回退为本地截断摘要。
|
||||
|
||||
> Redis 未初始化时服务进入 configuration-only 模式,仅提供 SPA、bootstrap 和探针。
|
||||
> 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;状态页会提示缺少的环境变量,不回显任何密钥,也不会伪造 Redis 持久化成功。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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`),静态资源编译时随二进制打包,无运行期本地文件。
|
||||
|
|
|
|||
43
src/main.rs
43
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?;
|
||||
|
|
|
|||
|
|
@ -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<RwLock<Option<serde_json::Value>>>,
|
||||
business_runtime: Arc<RwLock<Option<BusinessConfig>>>,
|
||||
business_revision: Arc<RwLock<u64>>,
|
||||
setup_missing: Arc<Vec<String>>,
|
||||
reload: Arc<ReloadCoordinator>,
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +67,16 @@ async fn ready() -> impl IntoResponse {
|
|||
(StatusCode::OK, "ready")
|
||||
}
|
||||
|
||||
async fn setup_status(State(app): State<AppState>) -> 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<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
|
@ -619,6 +631,26 @@ 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.
|
||||
pub fn router_configuration_setup(missing: Vec<String>) -> 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<S: ReliableState + 'static>(
|
||||
secrets: AuthSecrets,
|
||||
worker_token: SecretString,
|
||||
|
|
@ -672,9 +704,14 @@ pub fn router_with_worker_state_runtime_bootstrap<S: ReliableState + 'static>(
|
|||
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<S: ReliableState + 'static>(
|
||||
secrets: AuthSecrets,
|
||||
worker_token: SecretString,
|
||||
|
|
@ -683,6 +720,7 @@ fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>
|
|||
worker_handler: Arc<dyn WorkerHandler>,
|
||||
runtime: RuntimeConfigProvider,
|
||||
bootstrap_token: SecretString,
|
||||
setup_missing: Vec<String>,
|
||||
) -> 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<S: ReliableState + 'static>
|
|||
.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<S: ReliableState + 'static>
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in New Issue