feat(proxy): browser-backed MITM forward-proxy mode

The FlareSolverr /v1 contract only returns cookies + user-agent. Clients like
Prowlarr take those and re-fetch the target with their own HTTP stack, which is
re-challenged on sites whose Cloudflare clearance is bound to the solving
browser's connection fingerprint (e.g. 1337x) — no cookie is portable to a
plain HTTP client, so those indexers can't be used at all.

Add an optional HTTP(S) forward proxy (MITM_PROXY_ENABLED). Point such a client's
proxy at it (per-indexer HTTP proxy in Prowlarr) and every request — search and
the .torrent/magnet grab — is transparently re-issued through the browser pool,
returning the RAW response bytes so binary downloads pass through intact.

- ca.ts: self-generated CA (persisted) + on-demand per-host leaf certs
- server.ts: per-host loopback-TLS termination (Bun's node:tls can't drive a
  handshake via emit("connection") or honor SNICallback, so one listening TLS
  server per host is the reliable path); raw-byte capture via page.goto response
  body, with the download-event path for binaries; scrape() fallback solves CF
- /proxy-ca.crt route to fetch the CA for the client's trust store
- New env: MITM_PROXY_{ENABLED,PORT,CA_DIR,MAX_TIER,DEBUG}

Off by default; localhost-only by design (a MITM proxy can impersonate any host
to a client that trusts its CA).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
aziz66
2026-07-16 01:13:45 +04:00
co-authored by Claude Fable 5
parent 21ef01ac37
commit aa719dd1fa
10 changed files with 623 additions and 14 deletions
+19
View File
@@ -7,6 +7,25 @@ PORT=8191
PORT_WEB=3000 PORT_WEB=3000
PORT_DOCS=3001 PORT_DOCS=3001
# --- MITM forward-proxy mode (optional, off by default) ---
#
# Browser-backed HTTP(S) forward proxy. Point a client's proxy setting at it and every
# request it makes is transparently re-issued through the browser pool — for clients like
# Prowlarr that only consume cookies+UA from /v1 and then re-fetch themselves, which fails
# on sites whose Cloudflare clearance is bound to the browser's connection fingerprint.
#
# It terminates TLS with its own CA (persisted in MITM_PROXY_CA_DIR). Install that CA
# (GET /proxy-ca.crt, or the ca.crt file) into the client's trust store, then set the
# client's proxy to http://<trawl-host>:<MITM_PROXY_PORT>. Only expose it to trusted
# clients on a private interface — it can impersonate any host to a client that trusts it.
MITM_PROXY_ENABLED=false
MITM_PROXY_PORT=8192
MITM_PROXY_CA_DIR=/data/proxy-ca
# Cap the escalation tier the proxy uses (e.g. 3 to keep it off residential Tier 4). Blank = up to 4.
MITM_PROXY_MAX_TIER=
# Log one line per proxied request (noisy). Errors are always logged.
MITM_PROXY_DEBUG=false
# --- reCAPTCHA v2 audio solving (optional) --- # --- reCAPTCHA v2 audio solving (optional) ---
# #
# By default TRAWL uses Google's own free Speech API to transcribe reCAPTCHA # By default TRAWL uses Google's own free Speech API to transcribe reCAPTCHA
+12
View File
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- **MITM forward-proxy mode** (`MITM_PROXY_ENABLED`) — a browser-backed HTTP(S) forward proxy.
The FlareSolverr `/v1` contract only returns cookies + user-agent; clients like Prowlarr take
those and re-fetch the target with their own HTTP stack, which is re-challenged on sites whose
Cloudflare clearance is bound to the solving browser's connection fingerprint (e.g. 1337x) — no
cookie is portable to a plain client. Point such a client's HTTP proxy at this instead and every
request is transparently re-issued through the browser pool, returning the raw response bytes
(so `.torrent`/binary downloads pass through intact, not just HTML). Terminates TLS with a
self-generated CA (persisted to `MITM_PROXY_CA_DIR`, downloadable at `GET /proxy-ca.crt`) that
you install into the client's trust store. New env: `MITM_PROXY_ENABLED`, `MITM_PROXY_PORT`
(default 8192), `MITM_PROXY_CA_DIR`, `MITM_PROXY_MAX_TIER`, `MITM_PROXY_DEBUG`.
## [1.0.0] - 2026-07-10 ## [1.0.0] - 2026-07-10
### Changed ### Changed
+27
View File
@@ -114,6 +114,33 @@ http://localhost:8191 # running on the same host
http://trawl:8191 # running via Docker Compose on the same network http://trawl:8191 # running via Docker Compose on the same network
``` ```
### MITM forward-proxy mode (fingerprint-bound Cloudflare, e.g. 1337x)
Some sites bind their Cloudflare clearance to the solving browser's full connection
fingerprint. The `/v1` flow can't help there: Prowlarr keeps only the cookie + user-agent
and **re-fetches the page with its own HTTP client**, which Cloudflare re-challenges — the
cookie isn't portable. For those indexers, enable the browser-backed forward proxy and add
it to Prowlarr as an **HTTP proxy** (tag it onto just the affected indexers):
```env
MITM_PROXY_ENABLED=true
MITM_PROXY_PORT=8192
MITM_PROXY_CA_DIR=/data/proxy-ca # persist the CA (mount a volume)
```
1. Install the proxy's CA into the client's trust store so it accepts the per-host certs:
`curl http://<trawl-host>:8191/proxy-ca.crt` → add to the Prowlarr container's CA store
(e.g. a linuxserver `/custom-cont-init.d` script that copies it to
`/usr/local/share/ca-certificates/` and runs `update-ca-certificates`).
2. Prowlarr → Settings → Indexer Proxies → **HTTP**, host `<trawl-host>`, port `8192`, give it
a tag, and add that tag to the CF-fingerprint-bound indexer.
Every request that indexer makes (search **and** the `.torrent`/magnet grab) is then re-issued
through the browser pool and returns the exact bytes Cloudflare served the browser.
> ⚠️ A MITM proxy can impersonate any host to a client that trusts its CA. Only expose it on a
> private interface (localhost / a private Docker network), never publicly.
## Tiers ## Tiers
``` ```
+4 -2
View File
@@ -13,10 +13,12 @@
"@trawl/types": "workspace:*", "@trawl/types": "workspace:*",
"camoufox-js": "0.11.1", "camoufox-js": "0.11.1",
"elysia": "^1.4.29", "elysia": "^1.4.29",
"memoirist": "1.1.0" "memoirist": "1.1.0",
"node-forge": "^1.3.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^7.0.2", "typescript": "^7.0.2",
"@types/bun": "^1.3.14" "@types/bun": "^1.3.14",
"@types/node-forge": "^1.3.11"
} }
} }
+18
View File
@@ -25,4 +25,22 @@ export const residentialProxyPool =
process.env.RESIDENTIAL_PROXY_LIST_FILE || undefined, process.env.RESIDENTIAL_PROXY_LIST_FILE || undefined,
) ?? undefined ) ?? undefined
// ── MITM forward-proxy mode ────────────────────────────────────────────────────
// Optional browser-backed HTTP(S) forward proxy (apps/api/src/proxy). Off by default.
// When enabled, point a client's proxy setting at MITM_PROXY_PORT and every request it
// makes is re-issued through the browser pool — for clients (e.g. Prowlarr) that only
// consume cookies+UA from /v1 and re-fetch themselves, which fails on fingerprint-bound
// Cloudflare clearances. See proxy/server.ts for the full rationale.
export const MITM_PROXY_ENABLED = /^(1|true|yes)$/i.test(process.env.MITM_PROXY_ENABLED ?? "")
export const MITM_PROXY_PORT = Number(process.env.MITM_PROXY_PORT ?? "8192")
// CA cert + key live here (persist across restarts so the CA is installed once).
export const MITM_PROXY_CA_DIR = process.env.MITM_PROXY_CA_DIR ?? "/data/proxy-ca"
// Cap the tier the proxy will escalate to (e.g. keep it off residential Tier 4).
export const MITM_PROXY_MAX_TIER = process.env.MITM_PROXY_MAX_TIER
? (Number(process.env.MITM_PROXY_MAX_TIER) as 1 | 2 | 3 | 4)
: undefined
// Log one line per proxied request (method, url, status, content-type, bytes). Off by
// default — proxied clients can be chatty. Errors are always logged.
export const MITM_PROXY_DEBUG = /^(1|true|yes)$/i.test(process.env.MITM_PROXY_DEBUG ?? "")
export const startTime = Date.now() export const startTime = Date.now()
+37 -7
View File
@@ -1,19 +1,49 @@
import { Elysia } from "elysia" import { Elysia } from "elysia"
import { POOL_SIZE, PORT } from "./config" import {
import { initPool } from "./deps" MITM_PROXY_CA_DIR,
MITM_PROXY_DEBUG,
MITM_PROXY_ENABLED,
MITM_PROXY_MAX_TIER,
MITM_PROXY_PORT,
POOL_SIZE,
PORT,
} from "./config"
import { getDeps, initPool } from "./deps"
import { registerLifecycleHandlers } from "./lifecycle" import { registerLifecycleHandlers } from "./lifecycle"
import { startMitmProxy } from "./proxy/server"
import { healthRoute } from "./routes/health" import { healthRoute } from "./routes/health"
import { indexRoute } from "./routes/index" import { indexRoute } from "./routes/index"
import { proxyCaRoute } from "./routes/proxy-ca"
import { scrapeRoute } from "./routes/scrape" import { scrapeRoute } from "./routes/scrape"
import { statsRoute } from "./routes/stats" import { statsRoute } from "./routes/stats"
import { v1Route } from "./routes/v1" import { v1Route } from "./routes/v1"
new Elysia().use(indexRoute()).use(healthRoute()).use(statsRoute()).use(v1Route()).use(scrapeRoute()).listen(PORT) new Elysia()
.use(indexRoute())
.use(healthRoute())
.use(statsRoute())
.use(v1Route())
.use(scrapeRoute())
.use(proxyCaRoute())
.listen(PORT)
console.log(`[api] TRAWL starting on :${PORT} (pool: ${POOL_SIZE} browser${POOL_SIZE === 1 ? "" : "s"})`) console.log(`[api] TRAWL starting on :${PORT} (pool: ${POOL_SIZE} browser${POOL_SIZE === 1 ? "" : "s"})`)
initPool().catch((err) => { initPool()
console.error("[api] startup failed:", err) .then(() => {
process.exit(1) // Proxy needs a ready pool — start it only after the browsers are warm.
}) if (MITM_PROXY_ENABLED) {
startMitmProxy({
port: MITM_PROXY_PORT,
caDir: MITM_PROXY_CA_DIR,
deps: getDeps(),
maxTier: MITM_PROXY_MAX_TIER,
debug: MITM_PROXY_DEBUG,
})
}
})
.catch((err) => {
console.error("[api] startup failed:", err)
process.exit(1)
})
registerLifecycleHandlers() registerLifecycleHandlers()
+117
View File
@@ -0,0 +1,117 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import forge from "node-forge"
// A tiny on-the-fly certificate authority for the MITM forward proxy.
//
// The proxy terminates the client's TLS so it can re-issue each request through the
// browser pool (see server.ts). To do that it must present a certificate the client
// trusts for the *target* host. We generate one long-lived CA (persisted to disk so
// the same CA cert can be installed into the client's trust store once) and mint a
// short per-host leaf certificate on demand, signed by that CA.
//
// The CA private key never leaves this container. Installing the CA cert in a client
// lets THIS proxy impersonate any host to THAT client — so the proxy must only ever be
// reachable by the trusted client (e.g. bound to localhost / a private Docker netns).
export class MitmCa {
private readonly caCert: forge.pki.Certificate
private readonly caKey: forge.pki.PrivateKey
private readonly leafKeys: forge.pki.rsa.KeyPair
private readonly certCache = new Map<string, string>()
readonly caCertPem: string
readonly caCertPath: string
constructor(dir: string) {
mkdirSync(dir, { recursive: true })
this.caCertPath = join(dir, "ca.crt")
const keyPath = join(dir, "ca.key")
if (existsSync(this.caCertPath) && existsSync(keyPath)) {
this.caCertPem = readFileSync(this.caCertPath, "utf8")
this.caCert = forge.pki.certificateFromPem(this.caCertPem)
this.caKey = forge.pki.privateKeyFromPem(readFileSync(keyPath, "utf8"))
} else {
const { cert, key } = createCaCertificate()
this.caCert = cert
this.caKey = key
this.caCertPem = forge.pki.certificateToPem(cert)
writeFileSync(this.caCertPath, this.caCertPem)
writeFileSync(keyPath, forge.pki.privateKeyToPem(key), { mode: 0o600 })
}
// One leaf keypair shared across every minted host cert — only the certificate
// (subject + SAN) differs per host, so there's no need to pay RSA keygen per host.
this.leafKeys = forge.pki.rsa.generateKeyPair(2048)
}
// The shared leaf private key (PEM) — every minted host cert is signed for this key,
// so one key serves all per-host TLS servers.
get leafKeyPem(): string {
return forge.pki.privateKeyToPem(this.leafKeys.privateKey)
}
// Returns a leaf certificate (PEM) valid for `host`, minting + caching on first use.
// Serve the leaf ALONE: the client trusts our CA directly (it's the root), so no chain
// is needed. (Appending the CA made Bun's TLS stack pick the wrong end-entity cert.)
leafCertPem(host: string): string {
const cached = this.certCache.get(host)
if (cached) return cached
const pem = forge.pki.certificateToPem(this.mintLeaf(host))
this.certCache.set(host, pem)
return pem
}
private mintLeaf(host: string): forge.pki.Certificate {
const cert = forge.pki.createCertificate()
cert.publicKey = this.leafKeys.publicKey
cert.serialNumber = randomSerial()
// Backdate 1h to tolerate mild clock skew between proxy and client containers.
cert.validity.notBefore = new Date(Date.now() - 3600_000)
cert.validity.notAfter = new Date(Date.now() + 397 * 24 * 3600_000) // 397d — CA/B leaf max
const subject = [{ name: "commonName", value: host }]
cert.setSubject(subject)
cert.setIssuer(this.caCert.subject.attributes)
cert.setExtensions([
{ name: "basicConstraints", cA: false },
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
{ name: "extKeyUsage", serverAuth: true },
{ name: "subjectAltName", altNames: altNamesFor(host) },
])
cert.sign(this.caKey, forge.md.sha256.create())
return cert
}
}
function createCaCertificate(): { cert: forge.pki.Certificate; key: forge.pki.PrivateKey } {
const keys = forge.pki.rsa.generateKeyPair(2048)
const cert = forge.pki.createCertificate()
cert.publicKey = keys.publicKey
cert.serialNumber = randomSerial()
cert.validity.notBefore = new Date(Date.now() - 3600_000)
cert.validity.notAfter = new Date(Date.now() + 10 * 365 * 24 * 3600_000) // 10y
const attrs = [
{ name: "commonName", value: "TRAWL MITM Proxy CA" },
{ name: "organizationName", value: "TRAWL" },
]
cert.setSubject(attrs)
cert.setIssuer(attrs)
cert.setExtensions([
{ name: "basicConstraints", cA: true, critical: true },
{ name: "keyUsage", keyCertSign: true, cRLSign: true, critical: true },
])
cert.sign(keys.privateKey, forge.md.sha256.create())
return { cert, key: keys.privateKey }
}
// SAN must carry an IP entry (type 7) for literal-IP hosts and a DNS entry (type 2)
// otherwise, or strict clients reject the leaf.
function altNamesFor(host: string): forge.pki.CertificateField[] {
const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host)
return [isIp ? { type: 7, ip: host } : { type: 2, value: host }]
}
// 16 random hex bytes; leading 0 keeps it a positive integer for strict parsers.
function randomSerial(): string {
const bytes = forge.random.getBytesSync(16)
return `00${forge.util.bytesToHex(bytes)}`
}
+358
View File
@@ -0,0 +1,358 @@
import { readFileSync } from "node:fs"
import net from "node:net"
import tls from "node:tls"
import { isCloudflarePage, type OrchestratorDeps, scrape } from "@trawl/tiers"
import type { Cookie, SupportedMethod } from "@trawl/types"
import { MitmCa } from "./ca"
// Browser-backed MITM forward proxy.
//
// WHY THIS EXISTS: the FlareSolverr `/v1` contract only hands back cookies + user-agent.
// Clients like Prowlarr take those, then RE-FETCH the target with their own HTTP stack.
// Against sites whose Cloudflare clearance is bound to the solving browser's full
// connection fingerprint (not just cookie+UA), that re-fetch is re-challenged and fails —
// no cookie is portable to a plain HTTP client. See the FlareSolverr adapter for the
// legacy path.
//
// This proxy sidesteps that: point the client's HTTP(S) proxy at it (per-indexer in
// Prowlarr), and every request the client makes is transparently re-issued through the
// real browser pool via scrape(), so Cloudflare always 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.
const MAX_HEADER_BYTES = 64 * 1024
export interface MitmProxyOptions {
port: number
caDir: string
deps: OrchestratorDeps
maxTier?: 1 | 2 | 3 | 4
maxTimeout?: number
debug?: boolean
}
export function startMitmProxy(opts: MitmProxyOptions): { ca: MitmCa; server: net.Server } {
const ca = new MitmCa(opts.caDir)
// Per-host loopback TLS terminators. We know the target host from the CONNECT line, so
// each host gets its own real listening TLS server whose base cert is that host's leaf —
// no SNI routing needed (Bun's node:tls doesn't invoke SNICallback, and can't drive a
// handshake via emit("connection"), so a real listening server per host is the reliable
// 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>>()
function tlsPortFor(host: string): Promise<number> {
const existing = tlsPorts.get(host)
if (existing) return existing
const p = new Promise<number>((resolve, reject) => {
const srv = tls.createServer({ key: ca.leafKeyPem, cert: ca.leafCertPem(host) }, (tlsSocket) => {
tlsSocket.on("error", () => tlsSocket.destroy())
serveRequests(tlsSocket, host, opts)
})
srv.on("error", reject)
srv.listen(0, "127.0.0.1", () => resolve((srv.address() as net.AddressInfo).port))
})
tlsPorts.set(host, p)
return p
}
const server = net.createServer((clientSocket) => {
clientSocket.once("data", (first) => {
const firstLine = first.toString("latin1").split("\r\n", 1)[0] ?? ""
const [method, target] = firstLine.split(" ")
if (method === "CONNECT") {
void handleConnect(clientSocket, target ?? "", tlsPortFor)
} else {
// Plain-HTTP proxy request: "GET http://host/path HTTP/1.1"
handlePlainHttp(clientSocket, first, opts).catch(() => clientSocket.destroy())
}
})
clientSocket.on("error", () => clientSocket.destroy())
})
server.on("error", (err) => console.error("[proxy] server error:", err instanceof Error ? err.message : err))
server.listen(opts.port, () => {
console.log(`[proxy] MITM forward proxy on :${opts.port} (CA: ${ca.caCertPath})`)
})
return { ca, server }
}
// CONNECT host:port → 200, then bridge the raw client socket to the host's loopback TLS
// terminator. We pause first because reading the CONNECT line left the socket flowing —
// pipe() resumes it once the bridge is wired, so the client's ClientHello isn't dropped.
async function handleConnect(
clientSocket: net.Socket,
target: string,
tlsPortFor: (host: string) => Promise<number>,
): Promise<void> {
const host = target.split(":")[0] ?? ""
if (!host) {
clientSocket.destroy()
return
}
clientSocket.pause()
let port: number
try {
port = await tlsPortFor(host)
} catch {
clientSocket.destroy()
return
}
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n", () => {
const upstream = net.connect(port, "127.0.0.1", () => {
clientSocket.pipe(upstream)
upstream.pipe(clientSocket)
})
upstream.on("error", () => clientSocket.destroy())
clientSocket.on("error", () => upstream.destroy())
})
}
// Read one HTTP/1.1 request off the decrypted stream, re-issue it through the browser,
// and write the solved response back. We answer one request per TLS connection and close
// (Connection: close) — clients open a fresh CONNECT per request, which keeps parsing
// trivial and lets each request pick up the freshest cached session.
function serveRequests(stream: tls.TLSSocket, host: string, opts: MitmProxyOptions): void {
const chunks: Buffer[] = []
let total = 0
const onData = (chunk: Buffer) => {
chunks.push(chunk)
total += chunk.length
const buf = Buffer.concat(chunks)
const headerEnd = buf.indexOf("\r\n\r\n")
if (headerEnd === -1) {
if (total > MAX_HEADER_BYTES) stream.destroy()
return
}
const headerText = buf.subarray(0, headerEnd).toString("latin1")
const lines = headerText.split("\r\n")
const [method = "GET", path = "/"] = (lines[0] ?? "").split(" ")
const headers = parseHeaders(lines.slice(1))
const contentLength = Number(headers["content-length"] ?? "0")
const bodyStart = headerEnd + 4
const bodyAvailable = buf.length - bodyStart
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
const url = `https://${headers.host ?? host}${path}`
void reissue(stream, url, method as SupportedMethod, body, opts)
}
stream.on("data", onData)
}
async function reissue(
stream: tls.TLSSocket,
url: string,
method: SupportedMethod,
body: string | 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 run the full scrape() pipeline to
// solve the challenge (which refreshes the session cache) and retry the raw capture once.
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
for (let attempt = 0; attempt < 2; attempt++) {
const handle = await opts.deps.acquireBrowser(domain)
const page = await handle.context.newPage()
try {
const session = await opts.deps.loadSession(domain)
if (session?.cookies?.length) {
await handle.context.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()
}),
)
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 (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())
// Challenge interstitials are always small HTML — only sniff those, never binaries.
const looksHtml = /text\/html/i.test(contentType)
const challenged =
attempt === 0 &&
(status === 403 || status === 503) &&
looksHtml &&
isCloudflarePage(bodyBuf.toString("utf8", 0, 4096), {})
if (challenged) {
// Solve via the full tier pipeline (refreshes the cached session), then retry raw.
await scrape({ url, method, body, maxTier: opts.maxTier, maxTimeout }, opts.deps).catch(() => {})
continue
}
return { status, contentType, body: bodyBuf }
} finally {
await page.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) }
}
// 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)}`),
)
}
}
// 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 {
const head =
`HTTP/1.1 ${status} ${reason(status)}\r\n` +
`Content-Type: ${contentType}\r\n` +
`Content-Length: ${body.length}\r\n` +
"Connection: close\r\n\r\n"
sock.write(head)
sock.write(body)
sock.end()
}
function parseHeaders(lines: string[]): Record<string, string> {
const out: Record<string, string> = {}
for (const line of lines) {
const idx = line.indexOf(":")
if (idx > 0) out[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim()
}
return out
}
function reason(status: number): string {
const map: Record<number, string> = {
200: "OK",
403: "Forbidden",
404: "Not Found",
500: "Internal Server Error",
502: "Bad Gateway",
}
return map[status] ?? "OK"
}
+22
View File
@@ -0,0 +1,22 @@
import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { Elysia } from "elysia"
import { MITM_PROXY_CA_DIR, MITM_PROXY_ENABLED } from "../config"
// Serves the MITM proxy CA certificate for easy installation into a client's trust
// store (e.g. `curl http://trawl:8191/proxy-ca.crt`). Only mounted when proxy mode is on.
export function proxyCaRoute() {
const app = new Elysia()
if (!MITM_PROXY_ENABLED) return app
return app.get("/proxy-ca.crt", ({ set }) => {
const path = join(MITM_PROXY_CA_DIR, "ca.crt")
if (!existsSync(path)) {
set.status = 503
return "CA not generated yet — start the proxy first"
}
set.headers["content-type"] = "application/x-pem-file"
set.headers["content-disposition"] = 'attachment; filename="trawl-proxy-ca.crt"'
return readFileSync(path, "utf8")
})
}
+9 -5
View File
@@ -10,7 +10,7 @@
}, },
"apps/api": { "apps/api": {
"name": "@trawl/api", "name": "@trawl/api",
"version": "0.1.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@trawl/browser": "workspace:*", "@trawl/browser": "workspace:*",
"@trawl/tiers": "workspace:*", "@trawl/tiers": "workspace:*",
@@ -18,9 +18,11 @@
"camoufox-js": "0.11.1", "camoufox-js": "0.11.1",
"elysia": "^1.4.29", "elysia": "^1.4.29",
"memoirist": "1.1.0", "memoirist": "1.1.0",
"node-forge": "^1.3.1",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"@types/node-forge": "^1.3.11",
"typescript": "^7.0.2", "typescript": "^7.0.2",
}, },
}, },
@@ -32,7 +34,7 @@
}, },
"apps/web": { "apps/web": {
"name": "@trawl/web", "name": "@trawl/web",
"version": "0.1.0", "version": "1.0.0",
"devDependencies": { "devDependencies": {
"@nuxt/fonts": "^0.14.0", "@nuxt/fonts": "^0.14.0",
"@nuxt/image": "^2.0.0", "@nuxt/image": "^2.0.0",
@@ -47,7 +49,7 @@
}, },
"packages/browser": { "packages/browser": {
"name": "@trawl/browser", "name": "@trawl/browser",
"version": "0.1.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@trawl/types": "workspace:*", "@trawl/types": "workspace:*",
"camoufox-js": "^0.11.1", "camoufox-js": "^0.11.1",
@@ -61,7 +63,7 @@
}, },
"packages/tiers": { "packages/tiers": {
"name": "@trawl/tiers", "name": "@trawl/tiers",
"version": "0.1.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@trawl/browser": "workspace:*", "@trawl/browser": "workspace:*",
"@trawl/types": "workspace:*", "@trawl/types": "workspace:*",
@@ -74,7 +76,7 @@
}, },
"packages/types": { "packages/types": {
"name": "@trawl/types", "name": "@trawl/types",
"version": "0.1.0", "version": "1.0.0",
"devDependencies": { "devDependencies": {
"typescript": "^7.0.2", "typescript": "^7.0.2",
}, },
@@ -865,6 +867,8 @@
"@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="],
"@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],