Spaces:
Paused
Paused
File size: 33,863 Bytes
e84ef16 69f12cd 415355b 69f12cd 0c3bfb5 69f12cd f3738a2 d67cb92 e84ef16 d67cb92 69f12cd e84ef16 10515f8 e84ef16 10515f8 e84ef16 10515f8 e84ef16 10515f8 e84ef16 10515f8 e84ef16 69f12cd 415355b 69f12cd a2a3346 69f12cd a2a3346 415355b a2a3346 415355b 69f12cd 415355b 69f12cd 415355b 69f12cd 415355b 69f12cd a2a3346 69f12cd a2a3346 69f12cd e399eb0 a2a3346 69f12cd 415355b 69f12cd 415355b 69f12cd 415355b 69f12cd 415355b 69f12cd 415355b 69f12cd a2a3346 69f12cd 415355b 69f12cd 415355b 69f12cd 415355b 69f12cd 415355b 69f12cd 415355b 69f12cd 415355b 69f12cd 415355b 69f12cd 415355b 69f12cd 415355b 69f12cd a2a3346 415355b a2a3346 69f12cd 415355b 69f12cd f3738a2 415355b a2a3346 415355b f3738a2 415355b f3738a2 415355b a2a3346 415355b a2a3346 415355b e399eb0 415355b 0c3bfb5 a920dd4 0c3bfb5 a920dd4 0c3bfb5 a920dd4 415355b dc59771 e84ef16 10515f8 e84ef16 10515f8 e84ef16 dc59771 f49cc93 10515f8 f49cc93 10515f8 f49cc93 10515f8 f49cc93 10515f8 f49cc93 10515f8 f49cc93 10515f8 f49cc93 10515f8 f49cc93 69f12cd 415355b f49cc93 69f12cd f49cc93 69f12cd f49cc93 f3738a2 f49cc93 10515f8 f49cc93 10515f8 f49cc93 f3738a2 f49cc93 69f12cd 10515f8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 | 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}`);
|