mirror of
https://github.com/germondai/trawl.git
synced 2026-08-17 12:11:23 +02:00
feat(proxy): route traffic through tiered fallback
This commit is contained in:
+450
-225
@@ -1,40 +1,41 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import net from "node:net"
|
||||
import tls from "node:tls"
|
||||
import { detectChallengeType, isChallengeWall, type OrchestratorDeps, scrape } from "@trawl/tiers"
|
||||
import type { Cookie, SupportedMethod } from "@trawl/types"
|
||||
import {
|
||||
isValidMethod,
|
||||
type OrchestratorDeps,
|
||||
proxySanitizeHeaders,
|
||||
RESPONSE_HOP_BY_HOP_HEADERS,
|
||||
scrape,
|
||||
} from "@trawl/tiers"
|
||||
import { MitmCa } from "./ca"
|
||||
import { ChallengeCache } from "./challengeCache"
|
||||
import { directForwardHttp, directForwardHttps, type ForwardResult } from "./directForward"
|
||||
import { responseFromScrapeResult } from "./responsePolicy"
|
||||
|
||||
// Browser-backed MITM forward proxy.
|
||||
//
|
||||
// WHY THIS EXISTS: the FlareSolverr `/v1` contract only hands back cookies + user-agent.
|
||||
// HTTP clients that consume that contract re-fetch the target with their own HTTP stack
|
||||
// and get re-challenged on sites whose Cloudflare clearance is bound to the solving
|
||||
// browser's full connection fingerprint — the cookie alone isn't portable. This proxy
|
||||
// sidesteps that: point the client's HTTP(S) proxy at it and every request is re-issued
|
||||
// through the browser pool via scrape(), so Cloudflare sees the fingerprint it cleared.
|
||||
//
|
||||
// It is a MITM: it terminates the client's TLS using a per-host cert from our own CA
|
||||
// (ca.ts). Only expose it to trusted clients on a private interface.
|
||||
// General forward proxy with browser-backed challenge escalation. HTTPS is
|
||||
// MITM-terminated, so expose it only to clients that trust this instance's CA.
|
||||
|
||||
const MAX_HEADER_BYTES = 64 * 1024
|
||||
|
||||
const challengeCache = new ChallengeCache({ ttlMs: 5 * 60 * 1000 })
|
||||
|
||||
export interface MitmProxyOptions {
|
||||
port: number
|
||||
caDir: string
|
||||
deps: OrchestratorDeps
|
||||
// Required — caller resolves `MITM_PROXY_HOST` (or any default it wants) and passes
|
||||
// the resolved string here. Per-host internal TLS terminators stay on 127.0.0.1.
|
||||
host: string
|
||||
maxTier?: 1 | 2 | 3 | 4
|
||||
maxTimeout?: number
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
export function startMitmProxy(opts: MitmProxyOptions): {
|
||||
export interface MitmProxyHandle {
|
||||
ca: MitmCa
|
||||
server: net.Server
|
||||
} {
|
||||
tlsServers: tls.Server[]
|
||||
}
|
||||
|
||||
export function startMitmProxy(opts: MitmProxyOptions): MitmProxyHandle {
|
||||
const ca = new MitmCa(opts.caDir)
|
||||
|
||||
// Per-host loopback TLS terminators. We know the target host from the CONNECT line, so
|
||||
@@ -44,6 +45,7 @@ export function startMitmProxy(opts: MitmProxyOptions): {
|
||||
// path). On CONNECT we bridge the raw client socket to the matching server's loopback
|
||||
// port; it terminates TLS natively and hands us the decrypted stream.
|
||||
const tlsPorts = new Map<string, Promise<number>>()
|
||||
const tlsServers: tls.Server[] = []
|
||||
|
||||
function tlsPortFor(host: string): Promise<number> {
|
||||
const existing = tlsPorts.get(host)
|
||||
@@ -54,7 +56,15 @@ export function startMitmProxy(opts: MitmProxyOptions): {
|
||||
serveRequests(tlsSocket, host, opts)
|
||||
})
|
||||
srv.on("error", reject)
|
||||
srv.listen(0, "127.0.0.1", () => resolve((srv.address() as net.AddressInfo).port))
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const address = srv.address()
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("TLS terminator did not bind to a TCP port"))
|
||||
return
|
||||
}
|
||||
tlsServers.push(srv)
|
||||
resolve(address.port)
|
||||
})
|
||||
})
|
||||
tlsPorts.set(host, p)
|
||||
return p
|
||||
@@ -68,25 +78,39 @@ export function startMitmProxy(opts: MitmProxyOptions): {
|
||||
if (method === "CONNECT") {
|
||||
void handleConnect(clientSocket, target ?? "", tlsPortFor)
|
||||
} else {
|
||||
// Plain-HTTP proxy request: "GET http://host/path HTTP/1.1"
|
||||
// `first` arrives as Buffer from Bun's net.Socket 'data' event; the lib.dom.d.ts
|
||||
// type widens it to string|Buffer for cross-runtime compatibility, but we only
|
||||
// ever get bytes here.
|
||||
handlePlainHttp(clientSocket, first as Buffer, opts).catch(() => clientSocket.destroy())
|
||||
const initial = Buffer.isBuffer(first) ? first : Buffer.from(first)
|
||||
handlePlainHttp(clientSocket, initial, opts).catch(() => clientSocket.destroy())
|
||||
}
|
||||
})
|
||||
clientSocket.on("error", () => clientSocket.destroy())
|
||||
})
|
||||
|
||||
server.on("error", (err) => console.error("[proxy] server error:", err instanceof Error ? err.message : err))
|
||||
// Bind to loopback by default — a MITM proxy trusts whoever installs its CA, so it must
|
||||
// never be exposed off-host unless the operator explicitly opts in via MITM_PROXY_HOST.
|
||||
// The per-host internal TLS terminators (above) stay on 127.0.0.1 unconditionally.
|
||||
server.listen(opts.port, opts.host, () => {
|
||||
console.log(`[proxy] MITM forward proxy on ${opts.host}:${opts.port} (CA: ${ca.caCertPath})`)
|
||||
})
|
||||
|
||||
return { ca, server }
|
||||
return { ca, server, tlsServers }
|
||||
}
|
||||
|
||||
// Graceful shutdown — stops accepting new connections and closes existing ones.
|
||||
// Called from lifecycle.ts on SIGTERM/SIGINT before the browser pool shutdown.
|
||||
export async function shutdownMitmProxy(handle: MitmProxyHandle, timeoutMs = 5_000): Promise<void> {
|
||||
const serverClosed = new Promise<void>((resolve) => {
|
||||
if (!handle.server.listening) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
handle.server.close(() => resolve())
|
||||
})
|
||||
|
||||
for (const srv of handle.tlsServers) {
|
||||
const closeAllConnections = (srv as tls.Server & { closeAllConnections?: () => void }).closeAllConnections
|
||||
closeAllConnections?.call(srv)
|
||||
srv.close()
|
||||
}
|
||||
|
||||
await Promise.race([serverClosed, new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))])
|
||||
}
|
||||
|
||||
// CONNECT host:port → 200, then bridge the raw client socket to the host's loopback TLS
|
||||
@@ -150,227 +174,368 @@ function serveRequests(stream: tls.TLSSocket, host: string, opts: MitmProxyOptio
|
||||
if (contentLength > 0 && bodyAvailable < contentLength) return // wait for full body
|
||||
|
||||
stream.off("data", onData)
|
||||
const body = contentLength > 0 ? buf.subarray(bodyStart, bodyStart + contentLength).toString("utf8") : undefined
|
||||
// Raw body bytes — UTF-8 decoding would corrupt binary uploads (PDFs, .torrent
|
||||
// POSTs, etc.). proxyRequest() forwards them as Buffer.
|
||||
const body = contentLength > 0 ? buf.subarray(bodyStart, bodyStart + contentLength) : undefined
|
||||
const url = `https://${headers.host ?? host}${path}`
|
||||
|
||||
void reissue(stream, url, method as SupportedMethod, body, opts)
|
||||
// WebSocket upgrade: skip HTTP framing entirely, relay raw bytes between client
|
||||
// and upstream after the 101 Switching Protocols handshake. CF bypass doesn't
|
||||
// apply (most WS servers aren't behind CF; if they are, client retries through
|
||||
// browser which handles WS in-page).
|
||||
const upgradeValue = (headers.upgrade ?? "").toLowerCase()
|
||||
if (upgradeValue.includes("websocket")) {
|
||||
const target = new URL(`https://${headers.host ?? host}`)
|
||||
void proxyWebSocket(stream, method, path, headers, target.hostname, target.port ? Number(target.port) : 443, true)
|
||||
return
|
||||
}
|
||||
|
||||
void proxyRequest(stream, url, method, headers, body, opts)
|
||||
}
|
||||
|
||||
stream.on("data", onData)
|
||||
}
|
||||
|
||||
async function reissue(
|
||||
stream: tls.TLSSocket,
|
||||
// WebSocket relay for HTTPS-tunneled traffic (CONNECT → MITM-terminated).
|
||||
// Re-encrypts bytes via a fresh TLS connection to upstream; once the 101
|
||||
// Switching Protocols handshake completes, both sockets are raw-byte-piped
|
||||
// in both directions. Connection: close is handled by client disconnect or
|
||||
// upstream EOF.
|
||||
async function proxyWebSocket(
|
||||
clientSocket: net.Socket,
|
||||
method: string,
|
||||
path: string,
|
||||
headers: Record<string, string>,
|
||||
host: string,
|
||||
port: number,
|
||||
isHttps: boolean,
|
||||
): Promise<void> {
|
||||
const upstream = isHttps
|
||||
? tls.connect({ host, port, servername: host, minVersion: "TLSv1.2" })
|
||||
: net.createConnection({ host, port })
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const cleanup = () => {
|
||||
try {
|
||||
clientSocket.destroy()
|
||||
} catch {}
|
||||
try {
|
||||
upstream.destroy()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
upstream.once("error", () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
})
|
||||
clientSocket.once("error", () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
})
|
||||
|
||||
upstream.once(isHttps ? "secureConnect" : "connect", () => {
|
||||
// Forward the Upgrade request verbatim, minus hop-by-hop headers.
|
||||
const requestLines = [`${method} ${path} HTTP/1.1`]
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
const lower = k.toLowerCase()
|
||||
if (RESPONSE_HOP_BY_HOP_HEADERS.has(lower)) continue
|
||||
requestLines.push(`${k}: ${v}`)
|
||||
}
|
||||
requestLines.push("Connection: Upgrade", `Upgrade: ${headers.upgrade ?? "websocket"}`)
|
||||
upstream.write(`${requestLines.join("\r\n")}\r\n\r\n`)
|
||||
|
||||
// Read until end of HTTP response headers (\r\n\r\n). For 101 Switching
|
||||
// Protocols there is no body — once we see the blank line, both sides
|
||||
// speak raw WebSocket frames.
|
||||
const chunks: Buffer[] = []
|
||||
let totalBytes = 0
|
||||
const onData = (chunk: Buffer) => {
|
||||
chunks.push(chunk)
|
||||
totalBytes += chunk.length
|
||||
const combined = Buffer.concat(chunks, totalBytes)
|
||||
const headerEnd = combined.indexOf("\r\n\r\n")
|
||||
if (headerEnd < 0) {
|
||||
if (totalBytes > 64 * 1024) {
|
||||
upstream.off("data", onData)
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
return
|
||||
}
|
||||
upstream.off("data", onData)
|
||||
// Forward the entire response (including any bytes past the header
|
||||
// terminator — rare for 101 but defensive).
|
||||
clientSocket.write(combined.subarray(0, headerEnd + 4))
|
||||
if (headerEnd + 4 < combined.length) {
|
||||
clientSocket.write(combined.subarray(headerEnd + 4))
|
||||
}
|
||||
// Both sides now speak raw WebSocket frames — bidirectional byte pipe.
|
||||
upstream.pipe(clientSocket)
|
||||
clientSocket.pipe(upstream)
|
||||
resolve()
|
||||
}
|
||||
upstream.on("data", onData)
|
||||
})
|
||||
|
||||
upstream.once("close", () => {
|
||||
try {
|
||||
clientSocket.destroy()
|
||||
} catch {}
|
||||
resolve()
|
||||
})
|
||||
clientSocket.once("close", () => {
|
||||
try {
|
||||
upstream.destroy()
|
||||
} catch {}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// New request entry point used by serveRequests. Tier 0 = direct TCP/TLS forward
|
||||
// to upstream with challenge detection. On challenge, escalate to the existing
|
||||
// tier pipeline (`scrape()` from @trawl/tiers — Tier 1 plain HTTP, Tier 2 cached
|
||||
// browser session, Tier 3 fresh CF solve, Tier 4 residential).
|
||||
//
|
||||
async function proxyRequest(
|
||||
stream: net.Socket,
|
||||
url: string,
|
||||
method: SupportedMethod,
|
||||
body: string | undefined,
|
||||
method: string,
|
||||
clientHeaders: Record<string, string>,
|
||||
body: Buffer | undefined,
|
||||
opts: MitmProxyOptions,
|
||||
): Promise<void> {
|
||||
let domain: string
|
||||
try {
|
||||
domain = new URL(url).hostname
|
||||
} catch {
|
||||
writeResponse(stream, 400, Buffer.from("bad URL"))
|
||||
return
|
||||
}
|
||||
|
||||
// Trust the cache for repeat visits — skip Tier 0 entirely if we recently saw
|
||||
// a CF challenge here and jump straight to scrape().
|
||||
const cachedMode = challengeCache.get(domain)
|
||||
if (cachedMode === "cf") {
|
||||
return await serveViaScrape(stream, url, method, clientHeaders, body, opts)
|
||||
}
|
||||
|
||||
// Tier 0 performs a normal direct request. Small responses are buffered so
|
||||
// challenge detection sees the complete HTML; only explicit video/large-file
|
||||
// responses are returned as streams by directForward.
|
||||
const sanitized = proxySanitizeHeaders(clientHeaders) ?? {}
|
||||
const useHttps = url.startsWith("https://")
|
||||
let tier0: ForwardResult
|
||||
try {
|
||||
if (useHttps) {
|
||||
const parsed = new URL(url)
|
||||
tier0 = await directForwardHttps({
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : 443,
|
||||
method,
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
headers: sanitized,
|
||||
body,
|
||||
skipChallengeDetection: false,
|
||||
})
|
||||
} else {
|
||||
tier0 = await directForwardHttp({
|
||||
url,
|
||||
method,
|
||||
headers: sanitized,
|
||||
body,
|
||||
skipChallengeDetection: false,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
tier0 = { mode: "error", error: err instanceof Error ? err : new Error(String(err)) }
|
||||
}
|
||||
|
||||
if (tier0.mode === "error") {
|
||||
if (opts.debug) console.log(`[proxy] Tier 0 error for ${url}: ${tier0.error.message}`)
|
||||
return await serveViaScrape(stream, url, method, clientHeaders, body, opts)
|
||||
}
|
||||
|
||||
if (tier0.mode === "stream") {
|
||||
if (opts.debug) console.log(`[proxy] Tier 0 stream for ${url} -> ${tier0.status}`)
|
||||
challengeCache.set(domain, "direct")
|
||||
writeResponseFromStream(
|
||||
stream,
|
||||
tier0.status,
|
||||
tier0.headers,
|
||||
tier0.socket,
|
||||
tier0.contentType,
|
||||
body?.length ?? 0,
|
||||
tier0.prefix,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (tier0.challengeDetected) {
|
||||
if (opts.debug) console.log(`[proxy] Tier 0 challenge for ${url} -> escalating to scrape()`)
|
||||
challengeCache.set(domain, "cf")
|
||||
return await serveViaScrape(stream, url, method, clientHeaders, body, opts)
|
||||
}
|
||||
|
||||
challengeCache.set(domain, "direct")
|
||||
writeResponseFromBuffer(stream, tier0.status, tier0.headers, tier0.body, tier0.contentType)
|
||||
}
|
||||
|
||||
// Tier 1+ fallback: reissue through the existing browser-backed scrape pipeline.
|
||||
// Used when Tier 0 detects a challenge, encounters a network error, or sees the
|
||||
// domain in challengeCache as "cf".
|
||||
async function serveViaScrape(
|
||||
stream: net.Socket,
|
||||
url: string,
|
||||
method: string,
|
||||
clientHeaders: Record<string, string>,
|
||||
body: Buffer | undefined,
|
||||
opts: MitmProxyOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const res = await fetchRaw(url, method, body, opts)
|
||||
if (opts.debug) console.log(`[proxy] ${method} ${url} -> ${res.status} ${res.contentType} ${res.body.length}b`)
|
||||
writeResponse(stream, res.status || 200, res.body, res.contentType)
|
||||
} catch (err) {
|
||||
console.error("[proxy] reissue failed for", url, err instanceof Error ? err.message : err)
|
||||
writeResponse(stream, 502, Buffer.from(`TRAWL proxy error: ${err instanceof Error ? err.message : String(err)}`))
|
||||
}
|
||||
}
|
||||
|
||||
// Re-issue the request through the browser pool and return the RAW response bytes
|
||||
// (status + content-type + body). Raw bytes are essential: clients download .torrent
|
||||
// files through this proxy, and rendering them as HTML (page.content()) corrupts the
|
||||
// bencoded payload. Raw HTML is also what Cardigann-style parsers want.
|
||||
//
|
||||
// Fast path is a browser navigation reusing the domain's cached session (cf_clearance).
|
||||
// If that comes back as a Cloudflare interstitial, we rotate the proxy the same way
|
||||
// Tier 3 does (markBad → next()), retry with a fresh per-attempt context so the new
|
||||
// proxy actually applies (the pool-shared context can't be reconfigured mid-flight),
|
||||
// and only fall through to the full scrape() pipeline once proxy rotation is exhausted.
|
||||
async function fetchRaw(
|
||||
url: string,
|
||||
method: SupportedMethod,
|
||||
body: string | undefined,
|
||||
opts: MitmProxyOptions,
|
||||
): Promise<{ status: number; contentType: string; body: Buffer }> {
|
||||
const domain = new URL(url).hostname
|
||||
const maxTimeout = opts.maxTimeout ?? 60_000
|
||||
const proxyPool = opts.deps.proxyPool
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const handle = await opts.deps.acquireBrowser(domain)
|
||||
// Pick a fresh proxy per attempt — proxyPool.next() is sticky-per-domain until we
|
||||
// markBad(), at which point it rotates. No pool → no proxy, just reuse handle.context.
|
||||
const proxy = proxyPool?.next(domain) ?? undefined
|
||||
const createdFreshCtx = Boolean(proxy)
|
||||
const ctx = proxy
|
||||
? await handle.browser.newContext({
|
||||
viewport: null,
|
||||
proxy: { server: proxy },
|
||||
})
|
||||
: handle.context
|
||||
const page = await ctx.newPage()
|
||||
try {
|
||||
const session = await opts.deps.loadSession(domain)
|
||||
if (session?.cookies?.length) {
|
||||
await ctx.addCookies(session.cookies.map(toPlaywrightCookie))
|
||||
await page.setExtraHTTPHeaders({ "User-Agent": session.userAgent })
|
||||
}
|
||||
if (method !== "GET" || body !== undefined) {
|
||||
await page.route(url, (route: { continue: (o: Record<string, unknown>) => void }) =>
|
||||
route.continue({
|
||||
method,
|
||||
...(body !== undefined ? { postData: body } : {}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// A .torrent (application/x-bittorrent) makes Firefox start a DOWNLOAD instead of a
|
||||
// navigation, so page.goto aborts. Capture it via the download event and read the
|
||||
// saved file — this still uses the real browser network (correct fingerprint +
|
||||
// cf_clearance), just the file-download path instead of the document path.
|
||||
let download: PlaywrightDownload | undefined
|
||||
const downloadSeen = new Promise<void>((res) =>
|
||||
page.once("download", (d: PlaywrightDownload) => {
|
||||
download = d
|
||||
res()
|
||||
}),
|
||||
if (!isValidMethod(method)) {
|
||||
writeResponse(stream, 400, Buffer.from(`unsupported method: ${method}`), "text/plain; charset=utf-8")
|
||||
return
|
||||
}
|
||||
const scrapeResult = await scrape(
|
||||
{
|
||||
url,
|
||||
method,
|
||||
headers: proxySanitizeHeaders(clientHeaders) ?? undefined,
|
||||
body: body?.toString("utf8"),
|
||||
maxTier: opts.maxTier,
|
||||
maxTimeout: opts.maxTimeout,
|
||||
},
|
||||
opts.deps,
|
||||
)
|
||||
if (opts.debug)
|
||||
console.log(
|
||||
`[proxy] scrape() tier ${scrapeResult.tier} for ${url} -> ${scrapeResult.statusCode} html=${scrapeResult.html?.length ?? 0}b body=${scrapeResult.body?.length ?? 0}b`,
|
||||
)
|
||||
|
||||
let resp: PlaywrightResponse | null = null
|
||||
try {
|
||||
resp = await page.goto(url, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: maxTimeout,
|
||||
})
|
||||
} catch (err) {
|
||||
// A download navigation rejects goto — wait briefly for the download event to land.
|
||||
await Promise.race([downloadSeen, sleep(3000)])
|
||||
if (!download) throw err
|
||||
if (opts.debug) {
|
||||
for (const t of scrapeResult.timings) {
|
||||
console.log(` tier ${t.tier}: ${t.status} (${t.durationMs}ms) ${t.reason ?? ""}`)
|
||||
}
|
||||
|
||||
if (download) {
|
||||
const filePath = await download.path()
|
||||
const buf = filePath ? readFileSync(filePath) : Buffer.alloc(0)
|
||||
await download.delete().catch(() => {})
|
||||
return {
|
||||
status: 200,
|
||||
contentType: contentTypeFor(download.suggestedFilename()),
|
||||
body: buf,
|
||||
}
|
||||
}
|
||||
|
||||
const status: number = resp?.status() ?? 0
|
||||
const respHeaders: Record<string, string> = resp?.headers() ?? {}
|
||||
const contentType = respHeaders["content-type"] ?? "application/octet-stream"
|
||||
const bodyBuf = Buffer.from((await resp?.body()) ?? new Uint8Array())
|
||||
|
||||
// Challenge wall detection — universal across all solvable challenge types (CF,
|
||||
// Turnstile/hCaptcha/reCAPTCHA/GeeTest, Imperva, CAP). A "wall" is anything
|
||||
// blocking page access (4xx/5xx, or a lean interstitial stub at 200); in-page
|
||||
// widget captchas on accessible pages (status 200 + widget markers) are NOT walls
|
||||
// — return the page as-is.
|
||||
const challengeType = detectChallengeType(bodyBuf.toString("utf8", 0, 4096), respHeaders)
|
||||
const wall = isChallengeWall(status, bodyBuf.length, challengeType)
|
||||
|
||||
if (wall) {
|
||||
// Hit the wall on this IP — rotate to a fresh proxy next attempt.
|
||||
if (proxy && proxyPool) proxyPool.markBad(proxy)
|
||||
if (attempt === 0) {
|
||||
// Refresh the cache so attempt 1 has solved cookies to add. Awaited (not
|
||||
// fire-and-forget) so attempt 1 doesn't race the cache write.
|
||||
await scrape({ url, method, body, maxTier: opts.maxTier, maxTimeout }, opts.deps).catch(() => {})
|
||||
}
|
||||
// attempt === 1 still walled — fall through to the final scrape() below.
|
||||
} else {
|
||||
// Page is reachable (or just has an in-page widget — content is the answer).
|
||||
return { status, contentType, body: bodyBuf }
|
||||
}
|
||||
} finally {
|
||||
await page.close().catch(() => {})
|
||||
// Fresh per-attempt contexts must be closed explicitly or they leak.
|
||||
if (createdFreshCtx) await ctx.close().catch(() => {})
|
||||
opts.deps.releaseBrowser(handle.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Both raw attempts came back challenged — return whatever the solver produced as HTML.
|
||||
const solved = await scrape({ url, method, body, maxTier: opts.maxTier, maxTimeout }, opts.deps)
|
||||
return {
|
||||
status: solved.statusCode || 200,
|
||||
contentType: "text/html; charset=utf-8",
|
||||
body: Buffer.from(solved.html),
|
||||
// Browser tiers expose both the original navigation response and the
|
||||
// rendered DOM. For HTML, the latter is the solved page behind the challenge.
|
||||
// Binary responses retain their exact raw bytes.
|
||||
const response = responseFromScrapeResult(scrapeResult)
|
||||
if (opts.debug) {
|
||||
console.log(
|
||||
`[proxy] writeResponseFromBuffer status=${scrapeResult.statusCode || 200} payload=${response.body.length}b contentType=${response.contentType}`,
|
||||
)
|
||||
}
|
||||
writeResponseFromBuffer(
|
||||
stream,
|
||||
scrapeResult.statusCode || 200,
|
||||
response.headers,
|
||||
response.body,
|
||||
response.contentType,
|
||||
)
|
||||
} catch (err) {
|
||||
console.error("[proxy] scrape() failed for", url, err instanceof Error ? err.message : err)
|
||||
writeResponseFromBuffer(
|
||||
stream,
|
||||
502,
|
||||
{ "Content-Type": "text/plain; charset=utf-8" },
|
||||
Buffer.from(`TRAWL proxy error: ${err instanceof Error ? err.message : String(err)}`),
|
||||
"text/plain; charset=utf-8",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal plain-HTTP (non-TLS) proxy support, mainly for completeness / http:// targets.
|
||||
async function handlePlainHttp(clientSocket: net.Socket, first: Buffer, opts: MitmProxyOptions): Promise<void> {
|
||||
const headerText = first.toString("latin1")
|
||||
const line = headerText.split("\r\n", 1)[0] ?? ""
|
||||
const [method = "GET", absUrl = ""] = line.split(" ")
|
||||
if (!/^https?:\/\//.test(absUrl)) {
|
||||
clientSocket.destroy()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetchRaw(absUrl, method as SupportedMethod, undefined, opts)
|
||||
if (opts.debug)
|
||||
console.log(`[proxy] ${method} ${absUrl} (plain) -> ${res.status} ${res.contentType} ${res.body.length}b`)
|
||||
writeResponse(clientSocket, res.status || 200, res.body, res.contentType)
|
||||
} catch (err) {
|
||||
writeResponse(
|
||||
clientSocket,
|
||||
502,
|
||||
Buffer.from(`TRAWL proxy error: ${err instanceof Error ? err.message : String(err)}`),
|
||||
)
|
||||
// Accumulate full HTTP headers before dispatching — needed to detect Upgrade: websocket
|
||||
// and to know where the body starts. The first chunk from the main listener is already
|
||||
// a complete request in most cases (curl/Chromium send headers + small body in one packet),
|
||||
// so check the boundary inline before waiting for another 'data' event.
|
||||
const chunks: Buffer[] = [first]
|
||||
let total = first.length
|
||||
let onData: ((chunk: Buffer) => void) | undefined
|
||||
|
||||
const tryDispatch = (): boolean => {
|
||||
const buf = Buffer.concat(chunks, total)
|
||||
const headerEnd = buf.indexOf("\r\n\r\n")
|
||||
if (headerEnd < 0) {
|
||||
if (total > MAX_HEADER_BYTES) {
|
||||
clientSocket.destroy()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const headerText = buf.subarray(0, headerEnd).toString("latin1")
|
||||
const lines = headerText.split("\r\n")
|
||||
const [method = "GET", absUrl = ""] = (lines[0] ?? "").split(" ")
|
||||
const headers = parseHeaders(lines.slice(1))
|
||||
|
||||
// WebSocket upgrade on plain HTTP (ws://): relay raw bytes between client
|
||||
// and upstream after the 101 Switching Protocols handshake.
|
||||
const upgradeValue = (headers.upgrade ?? "").toLowerCase()
|
||||
if (upgradeValue.includes("websocket")) {
|
||||
try {
|
||||
const parsed = new URL(absUrl)
|
||||
const isHttps = parsed.protocol === "wss:"
|
||||
proxyWebSocket(
|
||||
clientSocket,
|
||||
method,
|
||||
`${parsed.pathname}${parsed.search}`,
|
||||
headers,
|
||||
parsed.hostname,
|
||||
parsed.port ? Number(parsed.port) : isHttps ? 443 : 80,
|
||||
isHttps,
|
||||
).catch(() => {})
|
||||
} catch {
|
||||
clientSocket.destroy()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (!/^https?:\/\//.test(absUrl)) {
|
||||
clientSocket.destroy()
|
||||
return true
|
||||
}
|
||||
|
||||
const contentLength = Number(headers["content-length"] ?? "0")
|
||||
if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
|
||||
writeResponseFromBuffer(
|
||||
clientSocket,
|
||||
400,
|
||||
{ "content-type": "text/plain; charset=utf-8" },
|
||||
Buffer.from("invalid Content-Length"),
|
||||
"text/plain; charset=utf-8",
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (buf.length < headerEnd + 4 + contentLength) return false
|
||||
if (onData) clientSocket.off("data", onData)
|
||||
|
||||
const body = contentLength > 0 ? buf.subarray(headerEnd + 4, headerEnd + 4 + contentLength) : undefined
|
||||
void proxyRequest(clientSocket, absUrl, method, headers, body, opts)
|
||||
return true
|
||||
}
|
||||
|
||||
if (tryDispatch()) return
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
onData = (chunk: Buffer) => {
|
||||
chunks.push(chunk)
|
||||
total += chunk.length
|
||||
if (tryDispatch()) resolve()
|
||||
}
|
||||
clientSocket.on("data", onData)
|
||||
clientSocket.once("error", () => {
|
||||
clientSocket.destroy()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Minimal structural types for the Playwright objects we touch — camoufox-js doesn't
|
||||
// re-export Playwright's types (see BrowserHandle in @trawl/types), so we shape just the
|
||||
// members we call.
|
||||
interface PlaywrightResponse {
|
||||
status(): number
|
||||
headers(): Record<string, string>
|
||||
body(): Promise<Buffer | Uint8Array>
|
||||
}
|
||||
interface PlaywrightDownload {
|
||||
path(): Promise<string | null>
|
||||
suggestedFilename(): string
|
||||
delete(): Promise<void>
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((res) => setTimeout(res, ms))
|
||||
|
||||
// Best-effort content type from a downloaded filename — mainly so *arr clients see
|
||||
// application/x-bittorrent for .torrent files.
|
||||
function contentTypeFor(filename: string): string {
|
||||
if (/\.torrent$/i.test(filename)) return "application/x-bittorrent"
|
||||
if (/\.nzb$/i.test(filename)) return "application/x-nzb"
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// Playwright's addCookies rejects unknown sameSite spellings — map/whitelist to its enum.
|
||||
function toPlaywrightCookie(c: Cookie): Record<string, unknown> {
|
||||
const ss = (c.sameSite ?? "").toLowerCase()
|
||||
const sameSite = ss === "strict" ? "Strict" : ss === "lax" ? "Lax" : ss === "none" ? "None" : undefined
|
||||
return {
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path,
|
||||
expires: c.expires,
|
||||
httpOnly: c.httpOnly,
|
||||
secure: c.secure,
|
||||
...(sameSite ? { sameSite } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function writeResponse(
|
||||
sock: net.Socket | tls.TLSSocket,
|
||||
status: number,
|
||||
body: Buffer,
|
||||
contentType = "text/html; charset=utf-8",
|
||||
): void {
|
||||
function writeResponse(sock: net.Socket, status: number, body: Buffer, contentType = "text/html; charset=utf-8"): void {
|
||||
const head =
|
||||
`HTTP/1.1 ${status} ${reason(status)}\r\n` +
|
||||
`Content-Type: ${contentType}\r\n` +
|
||||
@@ -381,6 +546,66 @@ function writeResponse(
|
||||
sock.end()
|
||||
}
|
||||
|
||||
// Buffered responses preserve end-to-end headers and derive a fresh body length.
|
||||
function writeResponseFromBuffer(
|
||||
sock: net.Socket,
|
||||
status: number,
|
||||
upstreamHeaders: Record<string, string>,
|
||||
body: Buffer,
|
||||
fallbackContentType: string,
|
||||
): void {
|
||||
const ct = upstreamHeaders["content-type"] ?? fallbackContentType
|
||||
const headerLines: string[] = [`HTTP/1.1 ${status} ${reason(status)}`]
|
||||
let emittedContentType = false
|
||||
for (const [name, value] of Object.entries(upstreamHeaders)) {
|
||||
const lower = name.toLowerCase()
|
||||
if (RESPONSE_HOP_BY_HOP_HEADERS.has(lower)) continue
|
||||
if (lower === "content-length") continue
|
||||
if (lower === "content-type") emittedContentType = true
|
||||
headerLines.push(`${name}: ${value}`)
|
||||
}
|
||||
if (!emittedContentType) headerLines.push(`Content-Type: ${ct}`)
|
||||
headerLines.push(`Content-Length: ${body.length}`)
|
||||
headerLines.push("Connection: close")
|
||||
sock.write(`${headerLines.join("\r\n")}\r\n\r\n`)
|
||||
sock.write(body)
|
||||
sock.end()
|
||||
}
|
||||
|
||||
// Streamed responses retain upstream transfer framing.
|
||||
function writeResponseFromStream(
|
||||
sock: net.Socket,
|
||||
status: number,
|
||||
upstreamHeaders: Record<string, string>,
|
||||
upstreamSocket: net.Socket,
|
||||
fallbackContentType: string,
|
||||
requestBodyLength: number,
|
||||
prefix?: Buffer,
|
||||
): void {
|
||||
const headerLines: string[] = [`HTTP/1.1 ${status} ${reason(status)}`]
|
||||
let emittedContentType = false
|
||||
for (const [name, value] of Object.entries(upstreamHeaders)) {
|
||||
const lower = name.toLowerCase()
|
||||
// The streamed bytes retain upstream HTTP/1.1 chunk framing, so preserve
|
||||
// Transfer-Encoding. Other hop-by-hop headers remain connection-local.
|
||||
if (RESPONSE_HOP_BY_HOP_HEADERS.has(lower) && lower !== "transfer-encoding") continue
|
||||
if (lower === "content-type") emittedContentType = true
|
||||
headerLines.push(`${name}: ${value}`)
|
||||
}
|
||||
if (!emittedContentType) headerLines.push(`Content-Type: ${fallbackContentType}`)
|
||||
if (requestBodyLength > 0) headerLines.push(`X-Forwarded-Body-Length: ${requestBodyLength}`)
|
||||
headerLines.push("Connection: close")
|
||||
// Write HTTP headers first, then prefix body bytes (which arrived in the same
|
||||
// TCP segment as upstream's response headers), then pipe the rest of the body.
|
||||
sock.write(`${headerLines.join("\r\n")}\r\n\r\n`)
|
||||
if (prefix?.length) sock.write(prefix)
|
||||
upstreamSocket.pipe(sock)
|
||||
sock.on("error", () => upstreamSocket.destroy())
|
||||
upstreamSocket.on("error", () => sock.destroy())
|
||||
upstreamSocket.on("end", () => sock.end())
|
||||
upstreamSocket.on("close", () => sock.end())
|
||||
}
|
||||
|
||||
function parseHeaders(lines: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const line of lines) {
|
||||
|
||||
Reference in New Issue
Block a user