Phase G2-1~G2-6: Code Execution Worker - Docker sandbox deployment, backend API (code_exec_admin.py), frontend CodeExecTab, security hardening (read-only FS, non-root, capability dropping, RLIMIT_AS)
This commit is contained in:
1 parent
909c013e2d
commit
9b3e15ace6
9 files changed
+1709
-447
No files matched your search
@@ -0,0 +1,42 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL maintainer="qihuaiflow"
|
||||
LABEL description="Code Execution Sandbox - isolated code runner for Python and JavaScript"
|
||||
|
||||
# Switch to Aliyun mirrors for faster downloads in China
|
||||
RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.aliyun.com/debian|g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& sed -i 's|http://deb.debian.org/debian-security|http://mirrors.aliyun.com/debian-security|g' /etc/apt/sources.list.d/debian.sources
|
||||
|
||||
# Install Node.js from Debian repos + essential tools
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
nodejs \
|
||||
npm \
|
||||
curl \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& ln -sf /usr/bin/nodejs /usr/bin/node 2>/dev/null || true
|
||||
|
||||
# Install Python packages with Aliyun mirror
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
fastapi==0.104.1 \
|
||||
uvicorn==0.24.0 \
|
||||
pydantic==2.5.2
|
||||
|
||||
# Create non-root user for security
|
||||
RUN useradd -m -s /bin/bash executor \
|
||||
&& mkdir -p /workspace /app \
|
||||
&& chown -R executor:executor /workspace /app
|
||||
|
||||
# Copy executor server
|
||||
COPY executor_server.py /app/executor_server.py
|
||||
RUN chown executor:executor /app/executor_server.py
|
||||
|
||||
WORKDIR /app
|
||||
USER executor
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "executor_server:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Code Execution Sandbox Server
|
||||
Runs inside a Docker container, accepts code execution requests.
|
||||
Supports: Python, JavaScript
|
||||
"""
|
||||
import asyncio
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
import time
|
||||
import resource
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI(title="Code Execution Sandbox", version="1.0.0")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
# Use /tmp for temp files (writable by executor user)
|
||||
# /workspace is a tmpfs mount owned by root
|
||||
WORKSPACE = Path("/tmp")
|
||||
WORKSPACE.mkdir(exist_ok=True)
|
||||
|
||||
# Max code length: 50KB
|
||||
MAX_CODE_LENGTH = 50 * 1024
|
||||
# Default timeout: 30s, max: 120s
|
||||
DEFAULT_TIMEOUT = 30
|
||||
MAX_TIMEOUT = 120
|
||||
|
||||
|
||||
class ExecuteRequest(BaseModel):
|
||||
language: str = Field(..., description="Programming language: python, javascript")
|
||||
code: str = Field(..., description="Source code to execute")
|
||||
stdin: str = Field(default="", description="Standard input for the program")
|
||||
timeout: int = Field(default=DEFAULT_TIMEOUT, ge=1, le=MAX_TIMEOUT, description="Execution timeout in seconds")
|
||||
|
||||
|
||||
class ExecuteResponse(BaseModel):
|
||||
success: bool
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int = 0
|
||||
execution_time_ms: float = 0
|
||||
error: Optional[str] = None
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy", "languages": ["python", "javascript"]}
|
||||
|
||||
|
||||
@app.get("/info")
|
||||
async def info():
|
||||
"""Return sandbox runtime info."""
|
||||
py_ver = subprocess.run(["python3", "--version"], capture_output=True, text=True).stdout.strip()
|
||||
try:
|
||||
node_ver = subprocess.run(["node", "--version"], capture_output=True, text=True).stdout.strip()
|
||||
except Exception:
|
||||
node_ver = "not installed"
|
||||
return {
|
||||
"python": py_ver,
|
||||
"nodejs": node_ver,
|
||||
"workspace": str(WORKSPACE),
|
||||
"max_code_length": MAX_CODE_LENGTH,
|
||||
"max_timeout": MAX_TIMEOUT,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/execute", response_model=ExecuteResponse)
|
||||
async def execute_code(req: ExecuteRequest):
|
||||
"""Execute code in a sandboxed subprocess."""
|
||||
language = req.language.lower().strip()
|
||||
code = req.code
|
||||
stdin_input = req.stdin
|
||||
timeout = min(req.timeout, MAX_TIMEOUT)
|
||||
|
||||
# Validate language
|
||||
if language not in ("python", "py", "python3", "javascript", "js", "node"):
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported language: {language}. Supported: python, javascript")
|
||||
|
||||
# Normalize language
|
||||
if language in ("py", "python3"):
|
||||
language = "python"
|
||||
if language in ("js", "node"):
|
||||
language = "javascript"
|
||||
|
||||
# Validate code length
|
||||
if len(code.encode("utf-8")) > MAX_CODE_LENGTH:
|
||||
raise HTTPException(status_code=400, detail=f"Code too long. Max {MAX_CODE_LENGTH // 1024}KB")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
if language == "python":
|
||||
result = await _run_python(code, stdin_input, timeout)
|
||||
elif language == "javascript":
|
||||
result = await _run_javascript(code, stdin_input, timeout)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported language: {language}")
|
||||
except asyncio.TimeoutError:
|
||||
execution_time = (time.time() - start_time) * 1000
|
||||
return ExecuteResponse(
|
||||
success=False,
|
||||
stdout="",
|
||||
stderr="",
|
||||
exit_code=-1,
|
||||
execution_time_ms=execution_time,
|
||||
error=f"Execution timed out after {timeout}s",
|
||||
)
|
||||
|
||||
execution_time_ms = (time.time() - start_time) * 1000
|
||||
result["execution_time_ms"] = round(execution_time_ms, 2)
|
||||
return result
|
||||
|
||||
|
||||
async def _run_python(code: str, stdin_input: str, timeout: int) -> dict:
|
||||
"""Execute Python code."""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".py", dir=WORKSPACE, delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(code)
|
||||
script_path = f.name
|
||||
|
||||
try:
|
||||
# Use resource limits
|
||||
def set_limits():
|
||||
# Memory limit: 256MB
|
||||
resource.setrlimit(resource.RLIMIT_AS, (256 * 1024 * 1024, 256 * 1024 * 1024))
|
||||
# CPU time limit: timeout + 5s grace
|
||||
resource.setrlimit(resource.RLIMIT_CPU, (timeout + 5, timeout + 5))
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"python3",
|
||||
"-u", # unbuffered
|
||||
script_path,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
preexec_fn=set_limits,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(input=stdin_input.encode("utf-8") if stdin_input else None),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise
|
||||
|
||||
stdout_str = stdout.decode("utf-8", errors="replace")
|
||||
stderr_str = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
# Truncate output to 500KB
|
||||
truncated = False
|
||||
if len(stdout_str.encode("utf-8")) > 500 * 1024:
|
||||
stdout_str = stdout_str[:500 * 1024]
|
||||
truncated = True
|
||||
if len(stderr_str.encode("utf-8")) > 500 * 1024:
|
||||
stderr_str = stderr_str[:500 * 1024]
|
||||
truncated = True
|
||||
|
||||
return {
|
||||
"success": proc.returncode == 0,
|
||||
"stdout": stdout_str,
|
||||
"stderr": stderr_str,
|
||||
"exit_code": proc.returncode or 0,
|
||||
"error": None,
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
finally:
|
||||
# Cleanup temp file
|
||||
try:
|
||||
os.unlink(script_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def _run_javascript(code: str, stdin_input: str, timeout: int) -> dict:
|
||||
"""Execute JavaScript code via Node.js."""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".js", dir=WORKSPACE, delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(code)
|
||||
script_path = f.name
|
||||
|
||||
try:
|
||||
def set_limits():
|
||||
# Only limit CPU time for JS — V8 needs large virtual address space
|
||||
# for CodeRange; rely on Docker --memory for actual RSS limit.
|
||||
resource.setrlimit(resource.RLIMIT_CPU, (timeout + 5, timeout + 5))
|
||||
|
||||
# Use Node.js to run the script with tight heap limits
|
||||
env = os.environ.copy()
|
||||
node_flags = "--max-old-space-size=32 --max-semi-space-size=8"
|
||||
env["NODE_OPTIONS"] = node_flags
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"node",
|
||||
"--max-old-space-size=32",
|
||||
"--max-semi-space-size=8",
|
||||
script_path,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
preexec_fn=set_limits,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(input=stdin_input.encode("utf-8") if stdin_input else None),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise
|
||||
|
||||
stdout_str = stdout.decode("utf-8", errors="replace")
|
||||
stderr_str = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
truncated = False
|
||||
if len(stdout_str.encode("utf-8")) > 500 * 1024:
|
||||
stdout_str = stdout_str[:500 * 1024]
|
||||
truncated = True
|
||||
if len(stderr_str.encode("utf-8")) > 500 * 1024:
|
||||
stderr_str = stderr_str[:500 * 1024]
|
||||
truncated = True
|
||||
|
||||
return {
|
||||
"success": proc.returncode == 0,
|
||||
"stdout": stdout_str,
|
||||
"stderr": stderr_str,
|
||||
"exit_code": proc.returncode or 0,
|
||||
"error": None,
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
finally:
|
||||
try:
|
||||
os.unlink(script_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
Reference in new issue
Block a user