//! HTTP 路由、请求 / 响应模型与 handler。 use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use axum::extract::{Extension, State}; use axum::http::{header, HeaderValue}; use axum::response::IntoResponse; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::routing::{get, post}; use axum::{Json, Router}; use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _}; use serde::{Deserialize, Serialize}; use tokio_stream::StreamExt; use tokio_stream::wrappers::ReceiverStream; use crate::auth::AuthPayload; use crate::error::ApiError; use crate::executor::{ExecError, ExecOutcome, StreamEvent}; use crate::AppState; /// 装配路由:GET /healthz、GET /metrics、POST /v1/exec、POST /v1/exec/stream、POST /v1/cancel。 /// 受保护端点经司命授权中间件;`/healthz` 与 `/metrics` 不受保护。 pub fn router(state: Arc) -> Router { let protected = Router::new() .route("/v1/exec", post(exec)) .route("/v1/exec/stream", post(exec_stream)) .route("/v1/cancel", post(cancel_exec)) .route("/v1/file/read", post(file_read)) .route("/v1/file/write", post(file_write)) .route("/v1/file/list", post(file_list)) .route("/v1/file/stat", post(file_stat)) .route_layer(axum::middleware::from_fn_with_state( state.clone(), crate::auth::require_authorization, )); Router::new() .route("/healthz", get(healthz)) .route("/metrics", get(metrics_handler)) .merge(protected) .layer(tower_http::trace::TraceLayer::new_for_http()) .layer(tower_http::limit::RequestBodyLimitLayer::new(2 * 1024 * 1024)) .with_state(state) } pub async fn healthz(State(state): State>) -> impl IntoResponse { let concurrent = state.executor.metrics().concurrent_value(); let max_concurrent = state.config.max_concurrent; // 收集预热池统计(如果有) let pool_stats = if let Some(pool) = &state.warm_pool { let metrics = pool.collect_metrics().await; let total: usize = metrics.values().map(|(idle, running, _)| (idle + running) as usize).sum(); Some(serde_json::json!({ "total_containers": total, "by_language": metrics.iter().map(|(lang, (idle, running, waiters))| { (lang.clone(), serde_json::json!({ "idle": idle, "running": running, "waiters": waiters, })) }).collect::>(), })) } else { None }; Json(serde_json::json!({ "status": "ok", "service": "hunjing-sandbox", "version": env!("CARGO_PKG_VERSION"), "load": { "concurrent": concurrent, "max_concurrent": max_concurrent, "utilization_pct": if max_concurrent > 0 { (concurrent as f64 / max_concurrent as f64 * 100.0).round() } else { 0.0 }, }, "config": { "auth_enabled": state.config.auth_enabled, "docker_enabled": state.config.docker_enabled, "default_isolation": &state.config.isolation_default, }, "warm_pool": pool_stats, })) } /// Prometheus 指标(text/plain, version=0.0.4)。不鉴权,便于抓取。 pub async fn metrics_handler(State(state): State>) -> impl IntoResponse { let body = state.executor.metrics().gather(); ( [( header::CONTENT_TYPE, HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"), )], body, ) } /// 执行请求体。`argv`(命令式)与 `code`+`language`(代码片段式)二选一,同时或都不给出返回 400。 #[derive(Debug, Deserialize)] pub struct ExecRequest { /// 命令参数数组,首个元素为可执行文件(命令模式)。 #[serde(default)] pub argv: Option>, /// 代码片段(code 模式);与 `argv` 互斥。沙箱转译为 argv 后走执行路径。 #[serde(default)] pub code: Option, /// code 模式语言,默认 `python`。P0 仅支持 python。 #[serde(default)] pub language: Option, #[serde(default)] pub stdin: Option, #[serde(default)] pub timeout_ms: Option, #[serde(default)] pub workdir: Option, #[serde(default)] pub env: HashMap, #[serde(default)] pub max_output_bytes: Option, #[serde(default)] pub trace_id: Option, /// 资源限额收紧(key: fsize/as/cpu/nproc/nofile → u64);只能比凭证授予/默认更严,不能放宽。 #[serde(default)] pub rlimits: Option>, /// 隔离策略:soft(默认,容器失败时降级 process)或 hard(容器失败直接报错,不降级)。 #[serde(default)] pub isolation_policy: Option, } #[derive(Debug, Serialize)] pub struct ExecResponse { pub ok: bool, pub exit_code: Option, pub stdout: String, pub stderr: String, pub duration_ms: u128, pub timeout: bool, pub cancelled: bool, pub truncated: bool, pub trace_id: String, /// 实际生效的隔离模式(process/container)。P0 期间 container 降级为 process。 pub isolation_mode: String, } /// 取消请求体:按 trace_id 定位运行中任务。 #[derive(Debug, Deserialize)] pub struct CancelRequest { pub trace_id: String, } #[derive(Debug, Serialize)] pub struct CancelResponse { pub cancelled: bool, pub trace_id: String, } /// 文件读取请求。`path` 为 base 内相对路径;`offset`/`limit` 字节级分页(可选)。 #[derive(Debug, Deserialize)] pub struct FileReadRequest { pub path: String, #[serde(default)] pub offset: Option, #[serde(default)] pub limit: Option, } /// 文件写入请求。`content_b64` 为 base64url-no-pad 编码(二进制安全,不经 shell)。 #[derive(Debug, Deserialize)] pub struct FileWriteRequest { pub path: String, pub content_b64: String, } /// 目录列出请求。`path` 默认 base 根(`.`)。 #[derive(Debug, Deserialize)] pub struct FileListRequest { #[serde(default = "default_list_path")] pub path: String, } fn default_list_path() -> String { ".".into() } /// 文件元信息请求。 #[derive(Debug, Deserialize)] pub struct FileStatRequest { pub path: String, } pub async fn exec( State(state): State>, Extension(auth): Extension, Json(req): Json, ) -> Result, ApiError> { validate_mutex(&req)?; let isolation = resolve_isolation(&state, &auth, req.isolation_policy.as_deref())?; let effective_mode = isolation.mode.clone(); let allow_fallback = isolation.allow_fallback; // 授权绑定校验:body_hash 按 code/argv 模式分别重算,必须与凭证一致(防 TTL 内重放)。 if let Some(expected) = &auth.body_hash { let actual = compute_request_body_hash(&req); if &actual != expected { return Err(ApiError::Unauthorized( "请求内容与授权凭证不匹配(body_hash 校验失败)".into(), )); } } // code → argv 转译。translation 持有 TempPath,在本函数作用域内存活至 run().await 结束 // (同步执行:run 返回时子进程已 open 脚本,drop 安全)。 let translation = translate_if_code(&req).await?; let effective_argv: Vec = match &translation { Some(t) => t.argv.clone(), None => req.argv.clone().unwrap(), }; if effective_argv[0].trim().is_empty() { return Err(ApiError::BadRequest("argv[0] 不能为空".into())); } let trace_id = req.trace_id.clone().unwrap_or_else(generate_trace_id); // 资源限额:凭证授予(司命 tool policy)+ 请求收紧,clamp 到全局天花板。 let effective = crate::rlimits::compute_effective( &state.config.rlimits, auth.rlimit_overrides.as_ref(), req.rlimits.as_ref(), ); // 并发闸:超出上限时排队,避免资源耗尽。 let _permit = state .semaphore .clone() .acquire_owned() .await .map_err(|_| ApiError::Unavailable("沙箱并发闸已关闭".into()))?; let env_pairs: Vec<(String, String)> = req.env.into_iter().collect(); let outcome: ExecOutcome = state .executor .run_routed( effective_argv, req.stdin, req.timeout_ms, req.workdir, env_pairs, req.max_output_bytes, trace_id.clone(), effective, &effective_mode, state.warm_pool.clone(), allow_fallback, ) .await .map_err(|e| match e { ExecError::InvalidArgs(m) => ApiError::BadRequest(m), ExecError::Internal { user_msg, detail } => { tracing::error!(error = %detail, "沙箱执行内部错误"); ApiError::Internal(user_msg.to_string()) } })?; Ok(Json(ExecResponse { ok: !outcome.timeout && !outcome.cancelled && outcome.exit_code == Some(0), exit_code: outcome.exit_code, stdout: outcome.stdout, stderr: outcome.stderr, duration_ms: outcome.duration_ms, timeout: outcome.timeout, cancelled: outcome.cancelled, truncated: outcome.stdout_truncated || outcome.stderr_truncated, trace_id, isolation_mode: effective_mode, })) } /// 流式执行:返回 SSE,实时推送 stdout / stderr 增量(base64 保真),末尾一个 done 事件。 pub async fn exec_stream( State(state): State>, Extension(auth): Extension, Json(req): Json, ) -> Result>>, ApiError> { validate_mutex(&req)?; let isolation = resolve_isolation(&state, &auth, req.isolation_policy.as_deref())?; let effective_mode = isolation.mode.clone(); let allow_fallback = isolation.allow_fallback; if let Some(expected) = &auth.body_hash { let actual = compute_request_body_hash(&req); if &actual != expected { return Err(ApiError::Unauthorized( "请求内容与授权凭证不匹配(body_hash 校验失败)".into(), )); } } let translation = translate_if_code(&req).await?; let effective_argv: Vec = match &translation { Some(t) => t.argv.clone(), None => req.argv.clone().unwrap(), }; if effective_argv[0].trim().is_empty() { return Err(ApiError::BadRequest("argv[0] 不能为空".into())); } let trace_id = req.trace_id.clone().unwrap_or_else(generate_trace_id); // 资源限额:凭证授予(司命 tool policy)+ 请求收紧,clamp 到全局天花板。 let effective = crate::rlimits::compute_effective( &state.config.rlimits, auth.rlimit_overrides.as_ref(), req.rlimits.as_ref(), ); let permit = state .semaphore .clone() .acquire_owned() .await .map_err(|_| ApiError::Unavailable("沙箱并发闸已关闭".into()))?; let env_pairs: Vec<(String, String)> = req.env.into_iter().collect(); // Clone effective_mode before moving into SSE closure; run_stream_routed borrows the original. let mode_for_sse = effective_mode.clone(); let rx = state .executor .run_stream_routed( effective_argv, req.stdin, req.timeout_ms, req.workdir, env_pairs, req.max_output_bytes, trace_id.clone(), effective, &effective_mode, state.warm_pool.clone(), Some(permit), allow_fallback, ) .await .map_err(|e| match e { ExecError::InvalidArgs(m) => ApiError::BadRequest(m), ExecError::Internal { user_msg, detail } => { tracing::error!(error = %detail, "沙箱执行内部错误"); ApiError::Internal(user_msg.to_string()) } })?; let stream = ReceiverStream::new(rx).map(move |event| { let _hold_script = &translation; // 持有 TempPath 至流结束 let ev = match event { StreamEvent::Stdout(b) => Event::default() .event("stdout") .data(BASE64_URL_SAFE_NO_PAD.encode(&b)), StreamEvent::Stderr(b) => Event::default() .event("stderr") .data(BASE64_URL_SAFE_NO_PAD.encode(&b)), StreamEvent::Done(o) => { let payload = serde_json::json!({ "exit_code": o.exit_code, "duration_ms": o.duration_ms, "timeout": o.timeout, "cancelled": o.cancelled, "truncated": o.stdout_truncated || o.stderr_truncated, "trace_id": trace_id, "isolation_mode": mode_for_sse, }); Event::default().event("done").data(payload.to_string()) } }; Ok::<_, axum::Error>(ev) }); Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))) } /// 取消运行中的任务(按 trace_id)。 /// /// 安全权衡:cancel 仅中止任务、不执行新命令,危害有限,故凭证不绑定 trace_id /// (验签 + 过期即可),与 exec 的 body_hash 强绑定不同。 pub async fn cancel_exec( State(state): State>, Extension(_auth): Extension, Json(req): Json, ) -> Result, ApiError> { if req.trace_id.trim().is_empty() { return Err(ApiError::BadRequest("trace_id 不能为空".into())); } let cancelled = state.executor.cancel(&req.trace_id).await; if cancelled { Ok(Json(CancelResponse { cancelled: true, trace_id: req.trace_id, })) } else { Err(ApiError::NotFound(format!( "任务不存在或已完成: {}", req.trace_id ))) } } // ============================== // 文件 IO 端点(不经 shell,二进制安全) // ============================== /// `POST /v1/file/read`:读取 base 内文件,返回 base64 内容 + 元信息。 pub async fn file_read( State(state): State>, Extension(auth): Extension, Json(req): Json, ) -> Result, ApiError> { verify_file_op_hash(&auth, "read", &req.path)?; let outcome = crate::fileio::file_read( state.executor.base(), state.executor.base_canon(), &req.path, req.offset, req.limit, state.config.max_file_bytes, ) .await .map_err(file_error_to_api)?; Ok(Json(outcome)) } /// `POST /v1/file/write`:写入 base 内文件(覆盖),content 为 base64url-no-pad。 pub async fn file_write( State(state): State>, Extension(auth): Extension, Json(req): Json, ) -> Result, ApiError> { verify_file_op_hash(&auth, "write", &req.path)?; let content = BASE64_URL_SAFE_NO_PAD .decode(req.content_b64.as_bytes()) .map_err(|_| ApiError::BadRequest("content_b64 不是合法 base64url-no-pad".into()))?; let outcome = crate::fileio::file_write( state.executor.base(), state.executor.base_canon(), &req.path, &content, state.config.max_file_bytes, ) .await .map_err(file_error_to_api)?; Ok(Json(outcome)) } /// `POST /v1/file/list`:列出 base 内目录条目(按名字排序)。 pub async fn file_list( State(state): State>, Extension(auth): Extension, Json(req): Json, ) -> Result, ApiError> { verify_file_op_hash(&auth, "list", &req.path)?; let outcome = crate::fileio::file_list( state.executor.base(), state.executor.base_canon(), &req.path, ) .await .map_err(file_error_to_api)?; Ok(Json(outcome)) } /// `POST /v1/file/stat`:返回 base 内文件/目录元信息。 pub async fn file_stat( State(state): State>, Extension(auth): Extension, Json(req): Json, ) -> Result, ApiError> { verify_file_op_hash(&auth, "stat", &req.path)?; let outcome = crate::fileio::file_stat( state.executor.base(), state.executor.base_canon(), &req.path, ) .await .map_err(file_error_to_api)?; Ok(Json(outcome)) } /// 校验文件操作凭证绑定:凭证 file_op_hash 必须与 {op, path} 摘要一致,防重放任意文件操作。 fn verify_file_op_hash(auth: &AuthPayload, op: &str, path: &str) -> Result<(), ApiError> { if let Some(expected) = &auth.file_op_hash { let actual = crate::auth::compute_file_op_hash(op, path); if &actual != expected { return Err(ApiError::Unauthorized( "文件操作与授权凭证不匹配(file_op_hash 校验失败)".into(), )); } } Ok(()) } /// 文件操作错误 → API 错误映射(路径问题 4xx,IO 内部错误脱敏后 500)。 fn file_error_to_api(e: crate::fileio::FileError) -> ApiError { use crate::fileio::FileError; match e { FileError::InvalidPath(m) => ApiError::BadRequest(m), FileError::NotFound => ApiError::NotFound("文件或目录不存在".into()), FileError::WrongType { expected, actual } => { ApiError::BadRequest(format!("类型不符:期望 {expected},实际 {actual}")) } FileError::TooLarge { limit, actual } => { ApiError::BadRequest(format!("内容超过大小上限 {limit} 字节(实际 {actual})")) } FileError::Io(e) => { tracing::error!(error = %e, "文件 IO 内部错误"); ApiError::Internal("文件操作失败".into()) } } } fn generate_trace_id() -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); format!("sbx_{nanos:x}") } /// argv / code 互斥:同时或都不给出 → 400。 fn validate_mutex(req: &ExecRequest) -> Result<(), ApiError> { match (req.argv.as_ref(), req.code.as_ref()) { (Some(_), Some(_)) => Err(ApiError::BadRequest("argv 与 code 不能同时指定".into())), (None, None) => Err(ApiError::BadRequest("必须指定 argv 或 code 之一".into())), _ => Ok(()), } } /// 计算请求内容摘要:code 模式按 {language, code, stdin},argv 模式按 {argv, stdin}。 fn compute_request_body_hash(req: &ExecRequest) -> String { if let Some(code) = req.code.as_deref() { let lang = req.language.as_deref().unwrap_or("python"); crate::auth::compute_body_hash_code(lang, code, &req.stdin) } else { let argv = req.argv.as_deref().unwrap_or_default(); crate::auth::compute_body_hash(argv, &req.stdin) } } /// code 模式转译为 argv;argv 模式返回 None。返回的 `CodeTranslation` 持有 `TempPath`, /// 调用方负责存活至执行结束(同步 exec 由作用域持有;流式 exec_stream 由 SSE 闭包持有)。 async fn translate_if_code( req: &ExecRequest, ) -> Result, ApiError> { if let Some(code) = req.code.as_deref() { let lang = req.language.as_deref().unwrap_or("python"); crate::codemode::translate(lang, code) .await .map(Some) .map_err(ApiError::BadRequest) } else { Ok(None) } } /// 生效隔离模式与降级策略。 struct IsolationConfig { /// 实际执行模式:"process" 或 "container"。 mode: String, /// 容器执行失败时是否允许降级为进程模式。 allow_fallback: bool, } /// 解析生效隔离模式与降级策略。 /// /// `auth.isolation_mode` 为凭证授予的隔离要求;`policy` 来自请求体 `isolation_policy` /// ("hard"=不降级 / "soft"=降级 / None=默认降级)。 fn resolve_isolation( state: &Arc, auth: &AuthPayload, policy: Option<&str>, ) -> Result { let hard = policy == Some("hard"); let fallback = |mode: &str, downgrade: bool| { let m = mode.to_string(); IsolationConfig { mode: m, allow_fallback: if downgrade { true } else { !hard }, } }; match auth.isolation_mode.as_deref() { Some("container") => { if state.warm_pool.is_some() { Ok(fallback("container", false)) } else if hard { Err(ApiError::Unavailable( "凭证要求 container 隔离但预热池未就绪,且 isolation_policy=hard 拒绝降级".into(), )) } else { tracing::warn!( tool = ?auth.tool, "凭证要求 container 隔离但预热池不可用,降级 process" ); Ok(fallback("process", true)) } } Some("process") => Ok(fallback("process", true)), Some(other) => { tracing::warn!(mode = other, "未知 isolation_mode,降级 process"); Ok(fallback("process", true)) } None => { if state.config.isolation_default == "container" && state.warm_pool.is_some() { Ok(fallback("container", false)) } else { Ok(fallback("process", true)) } } } } #[cfg(test)] mod tests { use super::*; #[test] fn exec_request_code_mode_deserializes() { let json = r#"{"language":"python","code":"print(1)"}"#; let req: ExecRequest = serde_json::from_str(json).unwrap(); assert_eq!(req.code.as_deref(), Some("print(1)")); assert_eq!(req.language.as_deref(), Some("python")); assert!(req.argv.is_none()); } #[test] fn exec_request_argv_mode_still_works() { let json = r#"{"argv":["python3","-c","print(1)"]}"#; let req: ExecRequest = serde_json::from_str(json).unwrap(); assert_eq!( req.argv.as_deref().unwrap(), &[ "python3".to_string(), "-c".to_string(), "print(1)".to_string() ] ); assert!(req.code.is_none()); } }