Files
hunjing/sandbox/src/api.rs
T
2026-08-21 16:00:13 +08:00

648 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<AppState>) -> 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<Arc<AppState>>) -> 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::<serde_json::Map<_, _>>(),
}))
} 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<Arc<AppState>>) -> 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<Vec<String>>,
/// 代码片段(code 模式);与 `argv` 互斥。沙箱转译为 argv 后走执行路径。
#[serde(default)]
pub code: Option<String>,
/// code 模式语言,默认 `python`。P0 仅支持 python。
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub stdin: Option<String>,
#[serde(default)]
pub timeout_ms: Option<u64>,
#[serde(default)]
pub workdir: Option<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub max_output_bytes: Option<usize>,
#[serde(default)]
pub trace_id: Option<String>,
/// 资源限额收紧(key: fsize/as/cpu/nproc/nofile → u64);只能比凭证授予/默认更严,不能放宽。
#[serde(default)]
pub rlimits: Option<BTreeMap<String, u64>>,
/// 隔离策略:soft(默认,容器失败时降级 process)或 hard(容器失败直接报错,不降级)。
#[serde(default)]
pub isolation_policy: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ExecResponse {
pub ok: bool,
pub exit_code: Option<i32>,
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<u64>,
#[serde(default)]
pub limit: Option<u64>,
}
/// 文件写入请求。`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<Arc<AppState>>,
Extension(auth): Extension<AuthPayload>,
Json(req): Json<ExecRequest>,
) -> Result<Json<ExecResponse>, 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<String> = 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<Arc<AppState>>,
Extension(auth): Extension<AuthPayload>,
Json(req): Json<ExecRequest>,
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<Event, axum::Error>>>, 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<String> = 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<Arc<AppState>>,
Extension(_auth): Extension<AuthPayload>,
Json(req): Json<CancelRequest>,
) -> Result<Json<CancelResponse>, 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<Arc<AppState>>,
Extension(auth): Extension<AuthPayload>,
Json(req): Json<FileReadRequest>,
) -> Result<Json<crate::fileio::FileReadOutcome>, 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<Arc<AppState>>,
Extension(auth): Extension<AuthPayload>,
Json(req): Json<FileWriteRequest>,
) -> Result<Json<crate::fileio::FileWriteOutcome>, 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<Arc<AppState>>,
Extension(auth): Extension<AuthPayload>,
Json(req): Json<FileListRequest>,
) -> Result<Json<crate::fileio::FileListOutcome>, 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<Arc<AppState>>,
Extension(auth): Extension<AuthPayload>,
Json(req): Json<FileStatRequest>,
) -> Result<Json<crate::fileio::FileStatOutcome>, 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<Option<crate::codemode::CodeTranslation>, 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<AppState>,
auth: &AuthPayload,
policy: Option<&str>,
) -> Result<IsolationConfig, ApiError> {
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());
}
}