diff --git a/.env.example b/.env.example index 927592c..48176d7 100644 --- a/.env.example +++ b/.env.example @@ -143,7 +143,9 @@ CODE_RUNNER_MAX_OUTPUT_BYTES=1048576 CODE_RUNNER_LANGUAGES=python,node DOCKER_SOCKET_PATH=/var/run/docker.sock -# Rate limits -RATE_LIMIT_CODE_EXEC_PER_SEC=0.2 +# Rate limits (governor keyed limiters) +# PER_SEC/BURST 控制单 IP 每秒突发;DAILY 为单 IP 每日总额。 +# 注意:PER_SEC 必须是正整数(governor 不支持小数,非整数会回退到默认 1)。 +RATE_LIMIT_CODE_EXEC_PER_SEC=1 RATE_LIMIT_CODE_EXEC_BURST=3 RATE_LIMIT_CODE_EXEC_DAILY=50 diff --git a/src/api/code_runner/execute.rs b/src/api/code_runner/execute.rs new file mode 100644 index 0000000..9aef43c --- /dev/null +++ b/src/api/code_runner/execute.rs @@ -0,0 +1,184 @@ +//! 代码执行 server functions:StartExec / GetExecResult。 +//! +//! StartExec 流程:速率限制 → 语言白名单 → 源码大小校验 → 入队(DashMap) +//! → spawn 后台 task(信号量限并发 + clamp_limits + run_in_container)。 +//! 返回 task_id 供前端轮询。GetExecResult 读取任务条目。 +//! +//! 错误脱敏:匿名可见错误(不支持的语言、超限、限流)返回中文消息;系统内部 +//! 异常(容器拉起失败等)记服务端日志,对前端返回统一「系统暂时不可用」。 + +// 与 posts / settings 模块一致:Dioxus `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。 +#![allow(clippy::unused_unit, deprecated)] + +use std::sync::{Arc, LazyLock}; +use std::time::Duration; + +use chrono::Utc; +use dioxus::prelude::*; +use tokio::sync::Semaphore; + +use crate::api::code_runner::languages::{is_supported_lang, LANGUAGES}; +use crate::api::code_runner::progress::{ + gc_old_tasks, insert_task, update_task_result, update_task_stage, EXEC_TASKS, +}; +use crate::api::code_runner::{ExecRequest, ExecResult, ExecStatus, ExecTask}; +use crate::api::rate_limit::{check_code_exec_limit, get_client_ip}; +use crate::infra::docker::run_in_container; +use crate::infra::runner_config::{clamp_limits, RUNNER_CONFIG}; + +/// 并发容器控制信号量,限制同时在跑的容器数量(`CODE_RUNNER_MAX_CONCURRENT`)。 +pub static RUNNER_SEMAPHORE: LazyLock> = + LazyLock::new(|| Arc::new(Semaphore::new(RUNNER_CONFIG.max_concurrent))); + +/// 从 FullstackContext 提取客户端 IP(无上下文时退回 "unknown")。 +fn client_ip() -> String { + match dioxus::fullstack::FullstackContext::current() { + Some(ctx) => { + let parts = ctx.parts_mut(); + get_client_ip(&parts.headers) + } + None => "unknown".to_string(), + } +} + +/// 提交一次代码执行请求。 +/// +/// 同步校验通过后立即返回 task_id,容器在后台执行;结果通过 +/// [`get_exec_result`] 轮询查询。语言不支持 / 源码过大 / 触发限流时同步返回错误。 +#[server(StartExec, "/api")] +pub async fn start_exec(req: ExecRequest) -> Result { + // 1. 速率限制(双层:每秒突发 + 每日总额) + let ip = client_ip(); + if let Err(msg) = check_code_exec_limit(&ip) { + return Err(ServerFnError::new(msg)); + } + + // 2. 语言白名单 + if !is_supported_lang(&req.language) { + return Err(ServerFnError::new("不支持该执行语言".to_string())); + } + + // 3. 源码大小限制 + if req.source.len() > RUNNER_CONFIG.max_source_bytes as usize { + return Err(ServerFnError::new("源代码过大".to_string())); + } + + // 4. 生成任务 ID 并入队 + let task_id = uuid::Uuid::new_v4().to_string(); + insert_task(task_id.clone()); + + // 5. 顺手回收过期任务 + gc_old_tasks(); + + // 6. 后台执行 + let task_id_clone = task_id.clone(); + let lang_key = req.language.clone(); + tokio::spawn(async move { + let sem = &*RUNNER_SEMAPHORE; + + // 排队等待可用容器槽 + let ticket = match tokio::time::timeout( + Duration::from_secs(RUNNER_CONFIG.queue_timeout_secs), + sem.acquire(), + ) + .await + { + Ok(Ok(t)) => t, + _ => { + update_task_stage(&task_id_clone, ExecStatus::Failed, "系统繁忙,排队超时"); + return; + } + }; + + update_task_stage(&task_id_clone, ExecStatus::Running, "启动容器"); + + let lang_def = match LANGUAGES.get(&lang_key) { + Some(d) => d, + None => { + // 理论不可达:start_exec 已校验白名单;防御性兜底。 + update_task_stage(&task_id_clone, ExecStatus::Failed, "语言未注册"); + return; + } + }; + + // 资源限制合并与钳制 + let base_limits = req + .overrides + .unwrap_or_else(|| lang_def.default_limits.clone()); + let final_limits = clamp_limits(base_limits, lang_def.allow_network); + + let start_time = Utc::now(); + + let res = run_in_container( + &lang_def.image, + &lang_def.run_cmd, + &req.source, + &lang_def.extension, + final_limits, + ) + .await; + + let duration_ms = (Utc::now() - start_time) + .num_milliseconds() + .max(0) as u64; + + drop(ticket); // 显式释放信号量 + + match res { + Ok((exit_code, stdout, stderr, oom_killed)) => { + let status = if oom_killed { + ExecStatus::OomKilled + } else if exit_code == Some(0) { + ExecStatus::Success + } else { + ExecStatus::Error + }; + let exec_res = ExecResult { + status: status.clone(), + stdout, + stderr, + exit_code, + duration_ms, + language: lang_key.clone(), + }; + update_task_result(&task_id_clone, status, exec_res); + } + Err(e) => { + // 系统内部异常脱敏:日志记详情,前端只见通用消息。 + let s = e.to_string(); + let is_timeout = s.contains("TimedOut"); + tracing::error!(error = ?e, task_id = %task_id_clone, "container execution failed"); + let status = if is_timeout { + ExecStatus::Timeout + } else { + ExecStatus::Failed + }; + let exec_res = ExecResult { + status: status.clone(), + stdout: "".to_string(), + stderr: if is_timeout { + "执行超时".to_string() + } else { + "系统暂时不可用".to_string() + }, + exit_code: None, + duration_ms, + language: lang_key.clone(), + }; + update_task_result(&task_id_clone, status, exec_res); + } + } + }); + + Ok(task_id) +} + +/// 查询任务执行结果(前端轮询)。 +#[server(GetExecResult, "/api")] +pub async fn get_exec_result(task_id: String) -> Result { + if let Some(task) = EXEC_TASKS.get(&task_id) { + Ok(task.clone()) + } else { + Err(ServerFnError::new("找不到指定的任务".to_string())) + } +} diff --git a/src/api/code_runner/mod.rs b/src/api/code_runner/mod.rs index c597826..e4f39df 100644 --- a/src/api/code_runner/mod.rs +++ b/src/api/code_runner/mod.rs @@ -72,7 +72,8 @@ pub struct ExecTask { } // 子模块在后续任务中实现: -// #[cfg(feature = "server")] pub mod execute; +#[cfg(feature = "server")] +pub mod execute; #[cfg(feature = "server")] pub mod languages; #[cfg(feature = "server")] diff --git a/src/api/rate_limit.rs b/src/api/rate_limit.rs index b9050f9..4bba3de 100644 --- a/src/api/rate_limit.rs +++ b/src/api/rate_limit.rs @@ -19,6 +19,8 @@ use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; use std::num::NonZeroU32; #[cfg(feature = "server")] use std::sync::LazyLock; +#[cfg(feature = "server")] +use std::time::Duration; #[cfg(feature = "server")] fn env_or(key: &str, default: u32) -> NonZeroU32 { @@ -61,6 +63,30 @@ static COMMENT_LIMITER: LazyLock> = LazyLock::ne ) }); +#[cfg(feature = "server")] +/// 代码执行单 IP 每秒限流(默认 0.2 req/s,突发 3)。 +/// 防止单个客户端高频提交容器任务,与下方日限额共同构成双层速率限制。 +static CODE_EXEC_LIMITER: LazyLock> = LazyLock::new(|| { + RateLimiter::keyed( + Quota::per_second(env_or("RATE_LIMIT_CODE_EXEC_PER_SEC", 1)) + .allow_burst(env_or("RATE_LIMIT_CODE_EXEC_BURST", 3)), + ) +}); + +#[cfg(feature = "server")] +/// 代码执行单 IP 每日限额(默认 50 次/天)。 +/// 容器执行成本高(CPU/内存/启动延迟),需要硬性日上限防止资源耗尽。 +/// +/// governor 0.8 无 `Quota::per_day`,用 `with_period(24h)` + `allow_burst(daily)` +/// 模拟:每 24h 补充 1 token、突发上限即日额度,等效于「每日最多 daily 次」。 +static CODE_EXEC_DAILY_LIMITER: LazyLock> = LazyLock::new(|| { + RateLimiter::keyed( + Quota::with_period(Duration::from_secs(86_400)) + .unwrap() + .allow_burst(env_or("RATE_LIMIT_CODE_EXEC_DAILY", 50)), + ) +}); + #[cfg(feature = "server")] /// 当无法识别真实客户端 IP("unknown")时使用的宽松限流桶。 /// @@ -226,6 +252,20 @@ pub fn check_upload_limit(ip: &str) -> Result<(), String> { .map_err(|_| "上传过于频繁,请稍后再试".to_string()) } +#[cfg(feature = "server")] +/// 检查代码执行请求的双层速率限制(每秒突发 + 每日总额)。 +/// +/// 两层任一被限即拒绝。返回中文错误消息,供 server function 直接透传给前端。 +pub fn check_code_exec_limit(ip: &str) -> Result<(), String> { + CODE_EXEC_LIMITER + .check_key(&ip.to_string()) + .map_err(|_| "请求过于频繁,请稍后再试".to_string())?; + CODE_EXEC_DAILY_LIMITER + .check_key(&ip.to_string()) + .map_err(|_| "今日运行次数已达上限,请明天再试".to_string())?; + Ok(()) +} + #[cfg(all(test, feature = "server"))] mod tests { use super::*;