diff --git a/api/python_exec_api.py b/api/python_exec_api.py index 7ab74f5..b95b5e4 100644 --- a/api/python_exec_api.py +++ b/api/python_exec_api.py @@ -1,41 +1,73 @@ -# /api/python_exec_api.py -from flask import Blueprint, request, jsonify +from flask import Blueprint, Flask, request from flask_restful import Api, Resource -import subprocess, tempfile, os +import subprocess, tempfile, os, requests python_exec_api = Blueprint('python_exec_api', __name__, url_prefix='/run') + api = Api(python_exec_api) +# todo: don't hardcode +RUNNER_URL = "http://code_runner:8591/python" + class PythonExec(Resource): def post(self): - """Executes submitted Python code safely in a short-lived subprocess.""" - data = request.get_json() + data = request.get_json(silent=True) or {} code = data.get("code", "") if not code.strip(): return {"output": "⚠️ No code provided."}, 400 - with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as tmp: - tmp.write(code.encode()) - tmp.flush() - - try: - result = subprocess.run( - ["python3", tmp.name], - capture_output=True, - text=True, - timeout=5, - cwd="/tmp", # Force working directory to /tmp - env={"HOME": "/tmp", "PATH": "/usr/bin:/usr/local/bin"} # Restricted environment - ) - output = result.stdout + result.stderr - except subprocess.TimeoutExpired: - output = "⏱️ Execution timed out (5 s limit)." - except Exception as e: - output = f"Error running code: {str(e)}" - finally: - os.unlink(tmp.name) - - return {"output": output} - -api.add_resource(PythonExec, "/python") \ No newline at end of file + is_production = os.environ.get("IS_PRODUCTION", "false").lower() == "true" + + if is_production: + return _execute_remote(data) + # might have to update this in future; could be vuln + # skipping verbose check + else: + return _execute_local(code) + + +def _execute_local(code): + with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as tmp: + tmp.write(code.encode()) + tmp.flush() + + try: + result = subprocess.run( + ["python3", tmp.name], + capture_output=True, + text=True, + timeout=5, + cwd="/tmp", # Force working directory to /tmp + env={"HOME": "/tmp", "PATH": "/usr/bin:/usr/local/bin"} # Restricted environment + ) + output = result.stdout + result.stderr + except subprocess.TimeoutExpired: + output = "Execution timed out (5 s limit)." + except Exception as e: + output = f"Error running code: {str(e)}" + finally: + os.unlink(tmp.name) + + return {"output": output} + +def _execute_remote(data): + try: + response = requests.post( + RUNNER_URL, + json=data, + timeout=10 + ) + + return response.json(), response.status_code + + except requests.Timeout: + return {"output": "Runner timed out."}, 504 + + except requests.RequestException as e: + return { + "output": f"Could not connect to code runner: {str(e)}" + }, 502 + + +api.add_resource(PythonExec, "/python") diff --git a/docker-compose.yml b/docker-compose.yml index 365688c..a671355 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,26 @@ version: '3' services: - web: - image: flask_open - build: . - env_file: - - .env # This file is optional; defaults will be used if it does not exist - ports: - - "8587:8587" - volumes: - - ./instance:/app/instance - restart: unless-stopped + web: + image: flask_open + build: . + env_file: + - .env # This file is optional; defaults will be used if it does not exist + ports: + - "8587:8587" + volumes: + - ./instance:/app/instance + restart: unless-stopped - socketio: - image: socket_open - build: ./socket - ports: - - "8500:8500" - - restart: unless-stopped \ No newline at end of file + socketio: + image: socket_open + build: ./socket + ports: + - "8500:8500" + restart: unless-stopped + + code_runner: + image: flask_runner + build: ./runner + cap_drop: + - ALL + restart: unless-stopped diff --git a/runner/Dockerfile b/runner/Dockerfile new file mode 100644 index 0000000..d14dba3 --- /dev/null +++ b/runner/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install Python deps first for better layer caching +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r /app/requirements.txt + +# Copy service code +COPY . /app + +EXPOSE 8591 + +# Run Flask with Gunicorn +CMD ["gunicorn", "--bind", "0.0.0.0:8591", "--workers", "2", "runner:runner"] diff --git a/runner/requirements.txt b/runner/requirements.txt new file mode 100644 index 0000000..e4a286c --- /dev/null +++ b/runner/requirements.txt @@ -0,0 +1,2 @@ +flask +gunicorn diff --git a/runner/runner.py b/runner/runner.py new file mode 100644 index 0000000..a9d2f96 --- /dev/null +++ b/runner/runner.py @@ -0,0 +1,41 @@ +from flask import Flask, jsonify, request +import subprocess, tempfile, os + +runner = Flask(__name__) + +@runner.post("/python") +def run_python(): + data = request.get_json() + code = data.get("code", "") + + if not code.strip(): + return {"output": "No code provided."}, 400 + + with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as tmp: + tmp.write(code.encode()) + tmp.flush() + + try: + result = subprocess.run( + ["python3", tmp.name], + capture_output=True, + text=True, + timeout=5, + cwd="/tmp", # Force working directory to /tmp + env={"HOME": "/tmp", "PATH": "/usr/bin:/usr/local/bin"} # Restricted environment + ) + output = result.stdout + result.stderr + except subprocess.TimeoutExpired: + output = "Execution timed out (5 s limit)." + except Exception as e: + output = f"Error running code: {str(e)}" + finally: + os.unlink(tmp.name) + + return {"output": output} + +if __name__ == "__main__": + host = "0.0.0.0" + port = 8591 + print(f"** Server running: http://localhost:{port}") # Pretty link + runner.run(debug=True, host=host, port=port, use_reloader=False)