初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+647
View File
@@ -0,0 +1,647 @@
//! 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());
}
}