Spaces:
Paused
Paused
| import { writeFile, unlink, mkdir, rm, access } from "node:fs/promises"; | |
| import { join } from "node:path"; | |
| import { randomBytes } from "node:crypto"; | |
| import os from "node:os"; | |
| const PORT = parseInt(Bun.env.PORT || "7860", 10); | |
| const TIMEOUT = parseInt(Bun.env.TIMEOUT_MS || "1800000", 10); // 30 minutes | |
| const HEARTBEAT_MS = 30000; | |
| const KILL_GRACE_MS = 500; | |
| const NODE_MODULES = "/app/node_modules:/app/helpers"; | |
| const RUNS_DIR = "/app/runs"; | |
| const SOLVER_URL = (Bun.env.TURNSTILE_SOLVER_URL || "").replace(/\/+$/, ""); | |
| const SERVICE_VERSION = "1.0.0"; | |
| const SERVER_STARTED_AT = Date.now(); | |
| const HOSTNAME = (() => { | |
| try { | |
| const r = Bun.spawnSync(["hostname"]); | |
| if (r?.exitCode === 0 && r.stdout) return r.stdout.toString().trim() || "unknown"; | |
| } catch {} | |
| try { return os.hostname() || "unknown"; } catch {} | |
| return "unknown"; | |
| })(); | |
| // Catch any uncaught error so the process logs WHY before exiting β otherwise | |
| // HF just shows 502 with no clue. | |
| // ---------- Pretty logging ---------- | |
| const C = { | |
| reset: "\x1b[0m", | |
| dim: "\x1b[2m", | |
| bold: "\x1b[1m", | |
| red: "\x1b[31m", | |
| green: "\x1b[32m", | |
| yellow:"\x1b[33m", | |
| blue: "\x1b[34m", | |
| magenta:"\x1b[35m", | |
| cyan: "\x1b[36m", | |
| gray: "\x1b[90m", | |
| }; | |
| function ts() { | |
| // 14:23:05.123 β short, sortable, fits the column header | |
| return new Date().toISOString().slice(11, 23); | |
| } | |
| function logLine(tag, tagColor, msg) { | |
| process.stdout.write( | |
| `${C.gray}${ts()}${C.reset} ${tagColor}${tag.padEnd(5)}${C.reset} ${msg}\n` | |
| ); | |
| } | |
| const log = { | |
| boot: (m) => logLine("boot", C.cyan, m), | |
| info: (m) => logLine("info", C.blue, m), | |
| warn: (m) => logLine("warn", C.yellow, m), | |
| error: (m) => logLine("error", C.red, m), | |
| fatal: (m) => logLine("FATAL", C.bold + C.red, m), | |
| }; | |
| process.on("uncaughtException", (err) => { | |
| log.fatal(`uncaughtException: ${err?.stack || err}`); | |
| process.exit(1); | |
| }); | |
| process.on("unhandledRejection", (err) => { | |
| log.fatal(`unhandledRejection: ${err?.stack || err}`); | |
| process.exit(1); | |
| }); | |
| log.boot(`bun ${Bun.version} Β· pid ${process.pid} Β· port ${PORT}`); | |
| try { | |
| await mkdir(RUNS_DIR, { recursive: true }); | |
| } catch (e) { | |
| log.error(`could not create RUNS_DIR (${RUNS_DIR}): ${e.message}`); | |
| } | |
| // Anti-miner pattern blocklist β kept on purpose (not a "limit", a security measure). | |
| const BLOCKED_PATTERNS = [ | |
| /stratum\+(tcp|ssl):\/\//i, | |
| /\b(xmrig|cn[-_]heavy|cryptonight|randomx|ethminer|nicehash|nanominer|t[-_]rex)\b/i, | |
| /\b(pool\.(minexmr|supportxmr|nanopool|hashvault|moneroocean|f2pool|2miners)|mining\.dwarfpool)\b/i, | |
| /\bcoinhive\b/i, | |
| /webminer/i, | |
| /\bnew\s+Worker\s*\(\s*['"`]data:text\/javascript;base64,/i, | |
| ]; | |
| function checkBlocked(code) { | |
| for (const re of BLOCKED_PATTERNS) { | |
| if (re.test(code)) return re.source; | |
| } | |
| return null; | |
| } | |
| // Pick file extension based on syntax. Node treats `.js` per the nearest | |
| // package.json `type` field, so we pin it explicitly: .cjs for require, | |
| // .mjs for import/export. Heuristic; user can hint via body.type. | |
| function detectExt(code, hint) { | |
| if (hint === "esm" || hint === "mjs" || hint === "module") return "mjs"; | |
| if (hint === "cjs" || hint === "commonjs" || hint === "script") return "cjs"; | |
| const esmRe = /(^|\n)\s*(import\s+[\w*{},\s]+\s+from\s+['"]|import\s*\(|export\s+(default|const|let|var|function|class|\{))/; | |
| const cjsRe = /(require\s*\(|module\.exports|exports\.[A-Za-z_$])/; | |
| const isEsm = esmRe.test(code); | |
| const isCjs = cjsRe.test(code); | |
| if (isEsm && !isCjs) return "mjs"; | |
| if (isCjs && !isEsm) return "cjs"; | |
| return "cjs"; // default β covers the original PlaywrightDownloader example | |
| } | |
| const shim = ` | |
| { | |
| const __origLog = console.log; | |
| console.log = (...args) => { | |
| const parts = args.map(a => { | |
| if (typeof a === 'string') return a; | |
| try { return JSON.stringify(a); } catch (e) { return String(a); } | |
| }); | |
| __origLog(parts.join(' ')); | |
| }; | |
| } | |
| `; | |
| let active = 0; | |
| let SETPRIV_OK = false; | |
| try { | |
| const probe = Bun.spawnSync(["setpriv", "--no-new-privs", "--", "true"]); | |
| SETPRIV_OK = probe.exitCode === 0; | |
| } catch { | |
| SETPRIV_OK = false; | |
| } | |
| // User code is run by `node`, not `bun`. Bun + Playwright has known pipe | |
| // communication issues (chromium child closes before parent connects). | |
| // `node` ships with the Playwright base image and works flawlessly. | |
| const NODE_BIN = (() => { | |
| try { | |
| const out = Bun.spawnSync(["which", "node"]).stdout.toString().trim(); | |
| if (out) return out; | |
| } catch {} | |
| return "/usr/bin/node"; | |
| })(); | |
| const CHILD_PATH = "/usr/local/bin:/usr/bin:/bin"; | |
| async function readAll(stream) { | |
| if (!stream) return ""; | |
| const reader = stream.getReader(); | |
| const chunks = []; | |
| let total = 0; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| chunks.push(value); | |
| total += value.byteLength; | |
| } | |
| const merged = new Uint8Array(total); | |
| let off = 0; | |
| for (const c of chunks) { merged.set(c, off); off += c.byteLength; } | |
| return new TextDecoder().decode(merged); | |
| } | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| // --- Burn: kill the entire process tree, then wipe the run workspace --- | |
| async function burn(pid, runDir, runId) { | |
| if (pid && pid > 1) { | |
| try { process.kill(-pid, "SIGKILL"); } catch {} | |
| try { process.kill(pid, "SIGKILL"); } catch {} | |
| } | |
| await sleep(KILL_GRACE_MS); | |
| if (pid && pid > 1) { | |
| try { Bun.spawnSync(["pkill", "-9", "-s", String(pid)]); } catch {} | |
| } | |
| if (runDir) { | |
| try { Bun.spawnSync(["pkill", "-9", "-f", runDir]); } catch {} | |
| try { await rm(runDir, { recursive: true, force: true }); } catch {} | |
| } | |
| // Only wipe ORPHANED chromium SHM β never touch files still held by a peer request. | |
| try { | |
| Bun.spawnSync(["bash", "-c", | |
| `for f in /dev/shm/.org.chromium.* /dev/shm/.com.google.Chrome* /dev/shm/pw-${runId}*; do ` + | |
| `[ -e "$f" ] || continue; ` + | |
| `fuser -s "$f" 2>/dev/null || rm -rf "$f" 2>/dev/null; ` + | |
| `done`, | |
| ]); | |
| } catch {} | |
| } | |
| async function runScript(code, hint) { | |
| const id = randomBytes(8).toString("hex"); | |
| const ext = detectExt(code, hint); | |
| const scriptFile = join(RUNS_DIR, `${id}.${ext}`); | |
| const runDir = `/tmp/run-${id}`; | |
| await mkdir(runDir, { recursive: true, mode: 0o700 }); | |
| await writeFile(scriptFile, shim + "\n" + code, { mode: 0o400 }); | |
| const cpuSec = Math.ceil(TIMEOUT / 1000) + 5; | |
| // Hardening (per-process). Most ulimits unlimited so chromium runs unimpeded; | |
| // only the genuine safety bits remain: | |
| // -t cpu seconds β still bounds runaway CPU (anti-miner, mirrors timeout) | |
| // -c 0 β no core dumps written to disk | |
| // umask 077 β any file the script writes is private to its run | |
| // setpriv --no-new-privs (block setuid escalation) when available. | |
| const ulimits = `umask 077; ulimit -t ${cpuSec} -c 0`; | |
| const runner = `${NODE_BIN} ${JSON.stringify(scriptFile)}`; | |
| const inner = SETPRIV_OK ? `setpriv --no-new-privs -- ${runner}` : runner; | |
| const wrapped = `${ulimits}; exec ${inner}`; | |
| const proc = Bun.spawn(["bash", "-c", wrapped], { | |
| stdout: "pipe", | |
| stderr: "pipe", | |
| detached: true, | |
| env: { | |
| PATH: CHILD_PATH, | |
| HOME: runDir, | |
| TMPDIR: runDir, | |
| CWD: runDir, | |
| NODE_PATH: NODE_MODULES, | |
| PLAYWRIGHT_BROWSERS_PATH: Bun.env.PLAYWRIGHT_BROWSERS_PATH || "/ms-playwright", | |
| LANG: "C.UTF-8", | |
| }, | |
| cwd: runDir, | |
| timeout: TIMEOUT + 5000, | |
| }); | |
| const childPid = proc.pid; | |
| let killedByTimer = false; | |
| const timer = setTimeout(() => { | |
| killedByTimer = true; | |
| burn(childPid, null, id).catch(() => {}); | |
| }, TIMEOUT); | |
| let stdoutText = ""; | |
| let stderrText = ""; | |
| let exitCode; | |
| try { | |
| [stdoutText, stderrText, exitCode] = await Promise.all([ | |
| readAll(proc.stdout), | |
| readAll(proc.stderr), | |
| proc.exited, | |
| ]); | |
| } finally { | |
| clearTimeout(timer); | |
| await burn(childPid, runDir, id); | |
| unlink(scriptFile).catch(() => {}); | |
| } | |
| return { | |
| timeout: killedByTimer, | |
| stdout: stdoutText, | |
| stderr: stderrText, | |
| exitCode, | |
| burned: true, | |
| moduleKind: ext === "mjs" ? "esm" : "cjs", | |
| }; | |
| } | |
| // Pretty-printed JSON. JSON.parse handles indented strings fine, so clients are | |
| // unaffected; humans hitting the URL in a browser get a readable response. | |
| function prettyJson(body) { | |
| return JSON.stringify(body, null, 2); | |
| } | |
| function jsonResponse(status, body) { | |
| return new Response(prettyJson(body) + "\n", { | |
| status, | |
| headers: { "Content-Type": "application/json; charset=utf-8" }, | |
| }); | |
| } | |
| // Streaming response with whitespace heartbeats β keeps long-running requests | |
| // alive past proxy idle timeouts. JSON.parse skips leading whitespace, so the | |
| // final body is still a single parseable JSON object. | |
| function streamingJson(workFn) { | |
| const enc = new TextEncoder(); | |
| const stream = new ReadableStream({ | |
| async start(controller) { | |
| const heartbeat = setInterval(() => { | |
| try { controller.enqueue(enc.encode(" ")); } catch {} | |
| }, HEARTBEAT_MS); | |
| let body; | |
| try { | |
| body = await workFn(); | |
| } catch (e) { | |
| body = { error: "internal", message: String(e?.message || e) }; | |
| } finally { | |
| clearInterval(heartbeat); | |
| } | |
| try { controller.enqueue(enc.encode(prettyJson(body) + "\n")); } catch {} | |
| try { controller.close(); } catch {} | |
| }, | |
| }); | |
| return new Response(stream, { | |
| status: 200, | |
| headers: { | |
| "Content-Type": "application/json; charset=utf-8", | |
| "Cache-Control": "no-cache, no-store", | |
| "X-Accel-Buffering": "no", | |
| }, | |
| }); | |
| } | |
| async function handleRun(req) { | |
| let body; | |
| try { | |
| body = await req.json(); | |
| } catch { | |
| return jsonResponse(400, { error: "invalid_json", message: "request body must be valid JSON" }); | |
| } | |
| const code = body?.code; | |
| if (!code || typeof code !== "string") { | |
| return jsonResponse(400, { | |
| error: "missing_code", | |
| message: 'JSON body must contain a "code" string', | |
| example: { code: "const { chromium } = require('playwright'); /* ... */" }, | |
| }); | |
| } | |
| const blocked = checkBlocked(code); | |
| if (blocked) { | |
| return jsonResponse(403, { | |
| error: "code_blocked", | |
| reason: "matched abuse pattern (anti-miner)", | |
| pattern: blocked, | |
| }); | |
| } | |
| active++; | |
| return streamingJson(async () => { | |
| try { | |
| const result = await runScript(code, body?.type); | |
| const payload = { | |
| ok: !result.timeout && result.exitCode === 0, | |
| moduleKind: result.moduleKind, | |
| output: result.stdout.trim(), | |
| exitCode: result.exitCode, | |
| burned: result.burned, | |
| }; | |
| if (result.stderr) payload.stderr = result.stderr; | |
| if (result.timeout) { | |
| payload.error = "timeout"; | |
| payload.timeoutMs = TIMEOUT; | |
| } | |
| return payload; | |
| } finally { | |
| active--; | |
| } | |
| }); | |
| } | |
| // ---------- Solver proxy endpoints ---------- | |
| // | |
| // These proxy the bundled (or compose-network) turnstile-solver service. | |
| // Caller doesn't need to know that solver exists or where it lives β the | |
| // API surface is local to this server. | |
| async function solverFetch(path, body, opts = {}) { | |
| if (!SOLVER_URL) { | |
| return { status: 503, body: { error: "solver_unavailable", message: "TURNSTILE_SOLVER_URL is not configured" } }; | |
| } | |
| const ctrl = new AbortController(); | |
| const timeout = opts.timeout ?? 90000; | |
| const t = setTimeout(() => ctrl.abort(), timeout); | |
| try { | |
| const resp = await fetch(`${SOLVER_URL}${path}`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(body), | |
| signal: ctrl.signal, | |
| }); | |
| const txt = await resp.text(); | |
| let parsed; | |
| try { parsed = JSON.parse(txt); } catch { parsed = { raw: txt }; } | |
| return { status: resp.status, body: parsed }; | |
| } catch (e) { | |
| if (e?.name === "AbortError") { | |
| return { status: 504, body: { error: "solver_timeout", timeoutMs: timeout } }; | |
| } | |
| return { status: 502, body: { error: "solver_unreachable", message: String(e?.message || e) } }; | |
| } finally { | |
| clearTimeout(t); | |
| } | |
| } | |
| // POST /solve { sitekey, siteurl, action?, cdata?, timeout? } β { token, elapsed } | |
| async function handleSolve(req) { | |
| let body; | |
| try { body = await req.json(); } catch { | |
| return jsonResponse(400, { error: "invalid_json" }); | |
| } | |
| const sitekey = (body?.sitekey || "").trim(); | |
| const siteurl = (body?.siteurl || "").trim(); | |
| if (!sitekey || !siteurl) { | |
| return jsonResponse(400, { | |
| error: "missing_fields", | |
| message: '"sitekey" and "siteurl" are required', | |
| example: { sitekey: "0x4AAAAAAA...", siteurl: "https://example.com" }, | |
| }); | |
| } | |
| active++; | |
| try { | |
| const { status, body: out } = await solverFetch("/solve", { | |
| sitekey, | |
| siteurl, | |
| action: body.action, | |
| cdata: body.cdata, | |
| timeout: body.timeout, | |
| }, { timeout: ((body.timeout || 90) + 15) * 1000 }); | |
| return jsonResponse(status, out); | |
| } finally { | |
| active--; | |
| } | |
| } | |
| // POST /solve-challenge { siteurl, timeout? } β { url, title, user_agent, cookies, html } | |
| async function handleSolveChallenge(req) { | |
| let body; | |
| try { body = await req.json(); } catch { | |
| return jsonResponse(400, { error: "invalid_json" }); | |
| } | |
| const siteurl = (body?.siteurl || "").trim(); | |
| if (!siteurl) { | |
| return jsonResponse(400, { | |
| error: "missing_fields", | |
| message: '"siteurl" is required', | |
| example: { siteurl: "https://target-with-cf.com" }, | |
| }); | |
| } | |
| active++; | |
| try { | |
| const { status, body: out } = await solverFetch("/solve-challenge", { | |
| siteurl, | |
| timeout: body.timeout, | |
| }, { timeout: ((body.timeout || 90) + 15) * 1000 }); | |
| return jsonResponse(status, out); | |
| } finally { | |
| active--; | |
| } | |
| } | |
| // POST /bypass { url, return?, timeout? } | |
| // return: "html" | "text" | "title" | "cookies" | "all" (default) | |
| // | |
| // Convenience: clear CF challenge via solver, then return whatever the caller | |
| // wants. This is the easy button β no Playwright code, just give a URL. | |
| async function handleBypass(req) { | |
| let body; | |
| try { body = await req.json(); } catch { | |
| return jsonResponse(400, { error: "invalid_json" }); | |
| } | |
| const url = (body?.url || body?.siteurl || "").trim(); | |
| if (!url) { | |
| return jsonResponse(400, { | |
| error: "missing_fields", | |
| message: '"url" is required', | |
| example: { url: "https://target-with-cf.com", return: "all" }, | |
| }); | |
| } | |
| const want = (body?.return || "all").toLowerCase(); | |
| active++; | |
| try { | |
| const { status, body: out } = await solverFetch("/solve-challenge", { | |
| siteurl: url, | |
| timeout: body.timeout, | |
| }, { timeout: ((body.timeout || 90) + 15) * 1000 }); | |
| if (status !== 200) return jsonResponse(status, out); | |
| // Shape the response per `return` selector | |
| if (want === "html") return new Response(out.html || "", { | |
| status: 200, | |
| headers: { "Content-Type": "text/html; charset=utf-8" }, | |
| }); | |
| if (want === "text") { | |
| const text = (out.html || "").replace(/<script[\s\S]*?<\/script>/gi, " ") | |
| .replace(/<style[\s\S]*?<\/style>/gi, " ") | |
| .replace(/<[^>]+>/g, " ") | |
| .replace(/\s+/g, " ") | |
| .trim(); | |
| return new Response(text, { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } }); | |
| } | |
| if (want === "title") return jsonResponse(200, { title: out.title || "", url: out.url }); | |
| if (want === "cookies") { | |
| const cookieHeader = (out.cookies || []).map(c => `${c.name}=${c.value}`).join("; "); | |
| return jsonResponse(200, { | |
| cookies: out.cookies || [], | |
| cookieHeader, | |
| userAgent: out.user_agent || "", | |
| url: out.url, | |
| }); | |
| } | |
| // default "all" | |
| return jsonResponse(200, { | |
| ok: true, | |
| url: out.url, | |
| title: out.title, | |
| userAgent: out.user_agent, | |
| cookies: out.cookies, | |
| cookieHeader: (out.cookies || []).map(c => `${c.name}=${c.value}`).join("; "), | |
| html: out.html, | |
| htmlLength: (out.html || "").length, | |
| elapsed: out.elapsed, | |
| }); | |
| } finally { | |
| active--; | |
| } | |
| } | |
| function infoPayload() { | |
| const now = Date.now(); | |
| const memMB = (n) => Math.round((n / 1024 / 1024) * 100) / 100; | |
| const mem = process.memoryUsage(); | |
| return { | |
| status: "ok", | |
| service: "playwright-api", | |
| version: SERVICE_VERSION, | |
| timestamp: new Date(now).toISOString(), | |
| runtime: { | |
| server: `bun ${Bun.version}`, | |
| userCode: `node (${NODE_BIN})`, | |
| v8: process.versions?.v8 || null, | |
| platform: process.platform, | |
| arch: process.arch, | |
| pid: process.pid, | |
| }, | |
| host: { | |
| hostname: HOSTNAME, | |
| cpus: os.cpus()?.length ?? null, | |
| loadAvg: os.loadavg(), | |
| totalMemMB: memMB(os.totalmem()), | |
| freeMemMB: memMB(os.freemem()), | |
| }, | |
| process: { | |
| startedAt: new Date(SERVER_STARTED_AT).toISOString(), | |
| uptimeSec: Math.round((now - SERVER_STARTED_AT) / 1000), | |
| activeRequests: active, | |
| memory: { | |
| rssMB: memMB(mem.rss), | |
| heapUsedMB: memMB(mem.heapUsed), | |
| heapTotalMB: memMB(mem.heapTotal), | |
| externalMB: memMB(mem.external), | |
| }, | |
| }, | |
| endpoints: [ | |
| { method: "GET", path: "/", desc: "HTML landing page" }, | |
| { method: "GET", path: "/api/info", desc: "this JSON info" }, | |
| { method: "GET", path: "/health", desc: "liveness probe" }, | |
| { method: "POST", path: "/run", desc: "execute Playwright code", body: { code: "string (CJS or ESM)" } }, | |
| { method: "POST", path: "/solve", desc: "Turnstile token from sitekey + siteurl", body: { sitekey: "string", siteurl: "string", action: "string?", cdata: "string?", timeout: "seconds?" } }, | |
| { method: "POST", path: "/solve-challenge", desc: "clear 'Just a moment' interstitial; returns cookies + html", body: { siteurl: "string", timeout: "seconds?" } }, | |
| { method: "POST", path: "/bypass", desc: "easy button: navigate + bypass CF + return html/text/title/cookies/all", body: { url: "string", return: "html|text|title|cookies|all" } }, | |
| ], | |
| solverProxy: { | |
| enabled: !!SOLVER_URL, | |
| url: SOLVER_URL || "(not configured)", | |
| examples: { | |
| solve: 'curl -X POST $URL/solve -H "Content-Type: application/json" -d \'{"sitekey":"0x4AAAA...","siteurl":"https://target.com"}\'', | |
| solveChallenge: 'curl -X POST $URL/solve-challenge -H "Content-Type: application/json" -d \'{"siteurl":"https://protected.com"}\'', | |
| bypass: 'curl -X POST $URL/bypass -H "Content-Type: application/json" -d \'{"url":"https://protected.com","return":"text"}\'', | |
| }, | |
| }, | |
| request: { | |
| method: "POST", | |
| path: "/run", | |
| contentType: "application/json", | |
| body: { | |
| code: "your Playwright script β `require` and `import` both work", | |
| type: "optional 'cjs' or 'esm' to override auto-detection", | |
| }, | |
| response: { | |
| ok: "boolean β true when exitCode === 0 and no timeout", | |
| moduleKind: "'cjs' or 'esm' β how the script was run", | |
| output: "string β stdout from your script (trimmed)", | |
| stderr: "string β stderr (only when present)", | |
| exitCode: "number β process exit code", | |
| burned: "true β workspace + process tree wiped on completion", | |
| error: "string β present on failure (timeout / internal)", | |
| }, | |
| example: { | |
| request: { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: { | |
| code: "const { chromium } = require('playwright');\n(async () => {\n const browser = await chromium.launch({ headless: true });\n const page = await browser.newPage();\n await page.goto('https://example.com');\n console.log(await page.title());\n await browser.close();\n})();", | |
| }, | |
| }, | |
| response: { ok: true, output: "Example Domain", exitCode: 0, burned: true }, | |
| }, | |
| longRunningSupport: `Server emits a single space character every ${HEARTBEAT_MS / 1000}s during execution to defeat proxy idle timeouts. Clients see a single parseable JSON object at the end (JSON.parse skips leading whitespace).`, | |
| }, | |
| timeout: { | |
| perRequestMs: TIMEOUT, | |
| perRequestHuman: `${Math.round(TIMEOUT / 60000)} minutes`, | |
| note: "Only timeout left β no body/code/output/concurrency limits.", | |
| }, | |
| hardening: { | |
| summary: "Each /run request is process-isolated, resource-capped, and burned (process tree killed + workspace wiped) on completion.", | |
| isolationPerRequest: "Each request gets its own pgid, sid, runDir, and PID. One request's burn never touches another in flight.", | |
| perRequestWorkspace: "/tmp/run-<16-hex-id>", | |
| homeRedirectedTo: "per-request workspace", | |
| tmpdirRedirectedTo: "per-request workspace", | |
| umask: "077", | |
| setpriv: SETPRIV_OK, | |
| noNewPrivs: SETPRIV_OK, | |
| envStripped: true, | |
| envExposedToChild: ["PATH", "HOME", "TMPDIR", "CWD", "NODE_PATH", "PLAYWRIGHT_BROWSERS_PATH", "LANG"], | |
| ulimits: { | |
| cpuSec: "TIMEOUT_MS/1000 + 5", | |
| coreDumps: 0, | |
| memory: "unlimited (host-wide cgroup is the real limiter)", | |
| fileSize: "unlimited", | |
| maxUserProcs: "unlimited", | |
| maxOpenFiles: "unlimited", | |
| }, | |
| burnOnComplete: true, | |
| burnSteps: [ | |
| "kill(-pid, SIGKILL) β kill entire process group", | |
| "pkill -9 -s <pid> β kill entire session (catches escapes)", | |
| "pkill -9 -f <runDir> β kill anything referencing this run's workspace", | |
| "rm -rf /tmp/run-<id> β wipe any binary/file the script wrote", | |
| "wipe orphaned /dev/shm chromium SHM (skips files held by other requests)", | |
| ], | |
| antiMinerBlocklist: BLOCKED_PATTERNS.map((re) => re.source), | |
| notSandbox: "This is hardening, not a sandbox. User code can still make outbound HTTP requests via Playwright. Mining / privilege escalation / persistent binaries / env leaks are mitigated, but a determined attacker may still abuse the egress path.", | |
| }, | |
| playwright: { | |
| packageVersion: "1.49.0", | |
| browsersPath: process.env.PLAYWRIGHT_BROWSERS_PATH || "/ms-playwright", | |
| baseImage: "mcr.microsoft.com/playwright:v1.49.0-jammy", | |
| browsers: { | |
| chromium: "bundled (headless_shell + headed)", | |
| firefox: "bundled", | |
| webkit: "bundled", | |
| chrome: "Google Chrome stable installed (use channel: 'chrome' for Widevine DRM)", | |
| }, | |
| fonts: ["liberation", "noto", "noto-color-emoji", "noto-cjk", "ipafont-gothic", "wqy-zenhei", "thai-tlwg", "kacst"], | |
| xvfb: "available β headless:false works via Xvfb if needed", | |
| }, | |
| stealth: { | |
| preinstalled: ["playwright-extra", "puppeteer-extra-plugin-stealth"], | |
| helperModule: "require('stealth') β pre-configured anti-detection wrapper", | |
| api: { | |
| "stealth.launch(opts)": "launch chromium with stealth plugin + anti-detect args", | |
| "stealth.launch({ channel: 'chrome' })": "launch real Google Chrome (Widevine DRM)", | |
| "stealth.context(browser, opts)": "context with realistic UA, viewport, locale, plugin shims", | |
| "stealth.gotoBypass(page, url)": "navigate + auto-handle CF (self-hosted solver if available, else local click)", | |
| "stealth.waitForCloudflare(page)": "standalone wait + auto-handle", | |
| "stealth.clickTurnstile(page)": "click 'Verify you are human' / Turnstile checkbox locally", | |
| }, | |
| }, | |
| captcha: { | |
| module: "require('captcha') β local solvers for several CAPTCHA classes", | |
| api: { | |
| "captcha.autoSolve(page)": "detect what CAPTCHA is on the page and try the right solver", | |
| "captcha.solveTurnstile(page)": "solve CF Turnstile (uses self-hosted solver if TURNSTILE_SOLVER_URL set, else local click)", | |
| "captcha.solveTurnstileViaSelfHosted(page)": "ask self-hosted solver for a token, inject it into the page", | |
| "captcha.clearChallenge(siteurl)": "POST /solve-challenge to self-hosted solver, returns { html, cookies, ... }", | |
| "captcha.clearChallengeAndAdoptCookies(context, siteurl)": "clear CF challenge and bring the cleared cookies into your Playwright context", | |
| "captcha.solveRecaptchaV2(page)": "full reCAPTCHA v2 audio-fallback flow (local Whisper)", | |
| "captcha.transcribe(audioBuffer)": "Whisper local transcription (English)", | |
| "captcha.ocrBuffer(imageBuffer)": "Tesseract local OCR (text/image CAPTCHAs)", | |
| "captcha.ocrUrl(page, url)": "fetch image with page cookies + OCR", | |
| "captcha.solveMath('2 + 3 * 4')": "safe math expression evaluator", | |
| "captcha.extractMath('What is 2 + 3?')": "find math expression in text and solve", | |
| "captcha.humanMove / humanClick / humanMoveAndClick": "bezier-curve mouse paths for natural cursor movement", | |
| "captcha.saveCookies / loadCookies / clearCookies": "per-origin cookie jar (skip re-solving challenges)", | |
| }, | |
| selfHostedSolver: { | |
| recommended: "https://github.com/cv3inx/turnstile-solver β deploy this as a separate service; gives token-based Turnstile solving without paid APIs", | |
| howToWire: "Set env TURNSTILE_SOLVER_URL=http://your-solver:9988 β captcha.solveTurnstile() will automatically use it", | |
| endpoints: { | |
| "POST /solve": "{ sitekey, siteurl } β { token } β used for sites with widget on page", | |
| "POST /solve-challenge": "{ siteurl } β { html, cookies, user_agent, ... } β used for full 'Just a moment' interstitial", | |
| }, | |
| }, | |
| whatItHandles: { | |
| "CF Turnstile checkbox": "self-hosted solver returns token, OR local human click (~70% pass rate without solver)", | |
| "CF 'Just a moment'": "self-hosted /solve-challenge returns clearance cookies; OR local stealth click", | |
| "reCAPTCHA v2 audio fallback": "switches to audio mode, transcribes locally with Whisper", | |
| "Text/image CAPTCHA (4-8 char)": "local Tesseract OCR", | |
| "Math CAPTCHA": "safe expression evaluator", | |
| "Human-like mouse movement": "bezier-curve paths, jitter, variable click hold", | |
| "Cookie persistence": "save/load CF clearance cookies per origin", | |
| }, | |
| whatItDoesNotHandle: { | |
| "hCaptcha image grid": "needs trained CV classifier β not realistic to ship in this repo. Use the self-hosted turnstile-solver only for Turnstile.", | |
| "reCAPTCHA v2 image challenge": "same", | |
| "CF Turnstile image challenge": "same β but a properly tuned self-hosted solver service often handles these", | |
| "CF Bot Management ML": "only the self-hosted solver has any chance", | |
| }, | |
| env: { | |
| TURNSTILE_SOLVER_URL: process.env.TURNSTILE_SOLVER_URL || "(not set β local fallbacks only)", | |
| TRANSFORMERS_CACHE: process.env.TRANSFORMERS_CACHE || "/app/.cache/transformers", | |
| TESSDATA_PREFIX: process.env.TESSDATA_PREFIX || "/app/tesseract-data", | |
| WHISPER_MODEL: process.env.WHISPER_MODEL || "Xenova/whisper-tiny.en", | |
| COOKIES_DIR: process.env.COOKIES_DIR || "/app/cookies", | |
| }, | |
| example: "const stealth = require('stealth');\nconst captcha = require('captcha');\nconst browser = await stealth.launch({ channel: 'chrome' });\nconst ctx = await stealth.context(browser);\nawait captcha.loadCookies(ctx, 'https://target.com'); // reuse prior CF clearance\nconst page = await ctx.newPage();\nawait stealth.gotoBypass(page, 'https://target.com'); // self-hosted solver if configured\nconst result = await captcha.autoSolve(page); // catch any extra captcha\nconsole.log(await page.title());\nawait captcha.saveCookies(ctx, 'https://target.com');\nawait browser.close();", | |
| }, | |
| }; | |
| } | |
| const PUBLIC_DIR = "/app/public"; | |
| const INDEX_HTML_PATH = `${PUBLIC_DIR}/index.html`; | |
| let HAS_INDEX_HTML = false; | |
| try { | |
| await access(INDEX_HTML_PATH); | |
| HAS_INDEX_HTML = true; | |
| log.boot(`landing page β ${INDEX_HTML_PATH}`); | |
| } catch { | |
| log.warn(`no index.html at ${INDEX_HTML_PATH} β / will return JSON instead`); | |
| } | |
| // ---------- Request logger ---------- | |
| // | |
| // Output is tabular so columns line up across rows: | |
| // 14:23:05.123 200 POST /bypass 12ms url=https://target.com ip=1.2.3.4 | |
| // 14:23:08.901 200 POST /run 5678ms code=2048b type=cjs ip=1.2.3.4 | |
| // 14:23:20.222 504 POST /solve 45003ms sitekey=0x4AAAA⦠ip=1.2.3.4 | |
| // 14:23:25.555 404 GET /run 1ms ip=1.2.3.4 | |
| // | |
| // /health, /api/info, /favicon.ico are quiet by default so Docker probes | |
| // don't spam the log; they're still logged when they return 4xx/5xx. | |
| const LOG_QUIET_PATHS = new Set(["/health", "/api/info", "/favicon.ico"]); | |
| const METHOD_COLORS = { | |
| GET: C.green, | |
| POST: C.cyan, | |
| PUT: C.yellow, | |
| DELETE: C.red, | |
| PATCH: C.magenta, | |
| }; | |
| function colorStatus(status) { | |
| if (status >= 500) return `${C.red}${status}${C.reset}`; | |
| if (status >= 400) return `${C.yellow}${status}${C.reset}`; | |
| if (status >= 300) return `${C.cyan}${status}${C.reset}`; | |
| return `${C.green}${status}${C.reset}`; | |
| } | |
| function colorMethod(method) { | |
| const c = METHOD_COLORS[method] || C.gray; | |
| return `${c}${method.padEnd(4)}${C.reset}`; | |
| } | |
| function fmtDuration(ms) { | |
| // Right-align in a 7-char column so durations stack visually | |
| const s = ms < 1000 ? `${ms}ms` | |
| : ms < 60000 ? `${(ms / 1000).toFixed(1)}s` | |
| : `${Math.round(ms / 1000)}s`; | |
| return s.padStart(7); | |
| } | |
| function clientIp(req, srv) { | |
| const peer = srv?.requestIP?.(req)?.address; | |
| if (peer && peer !== "::1" && peer !== "127.0.0.1") return peer; | |
| const fwd = req.headers.get("x-forwarded-for"); | |
| if (fwd) return fwd.split(",")[0].trim(); | |
| const real = req.headers.get("x-real-ip"); | |
| if (real) return real; | |
| return peer || "-"; | |
| } | |
| function truncate(s, n) { | |
| if (typeof s !== "string") return ""; | |
| return s.length > n ? s.slice(0, n - 1) + "β¦" : s; | |
| } | |
| async function readPayloadHints(req) { | |
| if (req.method !== "POST") return ""; | |
| const ct = (req.headers.get("content-type") || "").toLowerCase(); | |
| if (!ct.includes("application/json")) return ""; | |
| try { | |
| const txt = await req.clone().text(); | |
| if (!txt) return ""; | |
| const data = JSON.parse(txt); | |
| const hints = []; | |
| if (data.url) hints.push(`url=${truncate(String(data.url), 50)}`); | |
| else if (data.siteurl) hints.push(`siteurl=${truncate(String(data.siteurl), 50)}`); | |
| if (data.sitekey) hints.push(`sitekey=${truncate(String(data.sitekey), 14)}`); | |
| if (data.return) hints.push(`return=${data.return}`); | |
| if (data.code) hints.push(`code=${data.code.length}b`); | |
| if (data.type) hints.push(`type=${data.type}`); | |
| return hints.join(" "); | |
| } catch { | |
| return ""; | |
| } | |
| } | |
| let _headerPrinted = false; | |
| function printLogHeader() { | |
| if (_headerPrinted) return; | |
| _headerPrinted = true; | |
| process.stdout.write( | |
| `${C.dim}${"time".padEnd(12)} ${"sts".padEnd(3)} ${"method".padEnd(4)} ${"path".padEnd(20)} ${" dur".padEnd(7)} ${"hints".padEnd(40)} ip${C.reset}\n` + | |
| `${C.dim}${"β".repeat(12)} ${"β".repeat(3)} ${"β".repeat(4)} ${"β".repeat(20)} ${"β".repeat(7)} ${"β".repeat(40)} β${C.reset}\n` | |
| ); | |
| } | |
| async function route(req) { | |
| const url = new URL(req.url); | |
| if (req.method === "GET" && url.pathname === "/") { | |
| if (HAS_INDEX_HTML) { | |
| return new Response(Bun.file(INDEX_HTML_PATH), { | |
| status: 200, | |
| headers: { "Content-Type": "text/html; charset=utf-8" }, | |
| }); | |
| } | |
| return jsonResponse(200, infoPayload()); | |
| } | |
| if (req.method === "GET" && url.pathname === "/api/info") { | |
| return jsonResponse(200, infoPayload()); | |
| } | |
| if (req.method === "GET" && url.pathname === "/health") { | |
| return jsonResponse(200, { | |
| status: "ok", | |
| uptimeSec: Math.round((Date.now() - SERVER_STARTED_AT) / 1000), | |
| activeRequests: active, | |
| }); | |
| } | |
| if (req.method === "POST" && url.pathname === "/run") { | |
| return handleRun(req); | |
| } | |
| if (req.method === "POST" && url.pathname === "/solve") { | |
| return handleSolve(req); | |
| } | |
| if (req.method === "POST" && url.pathname === "/solve-challenge") { | |
| return handleSolveChallenge(req); | |
| } | |
| if (req.method === "POST" && url.pathname === "/bypass") { | |
| return handleBypass(req); | |
| } | |
| return jsonResponse(404, { | |
| error: "not_found", | |
| method: req.method, | |
| path: url.pathname, | |
| hint: "see GET / for available endpoints", | |
| }); | |
| } | |
| const server = Bun.serve({ | |
| port: PORT, | |
| hostname: "0.0.0.0", | |
| idleTimeout: 255, | |
| maxRequestBodySize: 1024 * 1024 * 1024, // 1 GiB β effectively no body limit | |
| async fetch(req, srv) { | |
| const t0 = performance.now(); | |
| const url = new URL(req.url); | |
| const path = url.pathname; | |
| const quiet = LOG_QUIET_PATHS.has(path); | |
| const hints = quiet ? "" : await readPayloadHints(req); | |
| const ip = clientIp(req, srv); | |
| const ua = req.headers.get("user-agent") || ""; | |
| let resp; | |
| let errored = null; | |
| try { | |
| resp = await route(req); | |
| } catch (e) { | |
| errored = e; | |
| resp = jsonResponse(500, { error: "internal", message: String(e?.message || e) }); | |
| } | |
| const ms = Math.round(performance.now() - t0); | |
| if (!quiet || resp.status >= 400) { | |
| printLogHeader(); | |
| const hintsPad = truncate(hints, 40).padEnd(40); | |
| const pathPad = truncate(path, 20).padEnd(20); | |
| process.stdout.write( | |
| `${C.gray}${ts()}${C.reset} ${colorStatus(resp.status)} ${colorMethod(req.method)} ${pathPad} ${fmtDuration(ms)} ${C.dim}${hintsPad}${C.reset} ${C.gray}${ip}${C.reset}\n` | |
| ); | |
| if (errored) { | |
| process.stdout.write(` ${C.red}ββ${C.reset} ${errored?.stack || errored}\n`); | |
| } | |
| } | |
| return resp; | |
| }, | |
| }); | |
| log.boot(`listening on :${server.port} Β· setpriv=${SETPRIV_OK} Β· solver=${SOLVER_URL || "off"}`); | |
| log.boot(`ready Β· ${C.dim}GET / for the dashboard${C.reset}`); | |