feat(server): /v1/fetch endpoint backed by handler-owned browsers - #6
Merged
Conversation
Adds a generic proxy that runs a single HTTP request from inside the
same browser session a Cloudflare/PerimeterX handler would have spawned
for `/v1/solve`. Solves the JA3/UA cliff downstream Rust clients hit
when replaying the cookie bundle: the response comes from the real
Firefox so cookies, TLS ClientHello, and User-Agent are all consistent
to the upstream WAF.
What's wired
- New `Fetcher` port trait in `px-pipeline` next to `ChallengeHandler`,
with `FetchRequest` / `FetchResponse` value types.
- `CamoufoxPool` now implements `Fetcher` (in addition to `Harvester`).
The new impl navigates to the target URL's origin, waits 1.5s for
CF/PX to issue cookies, then executes a `fetch()` from the page
context via `execute_async`, capturing status / headers / body. Both
Harvester and Fetcher share `CamoufoxPool::with_session`, extracted
alongside the shared `caps` helpers (`pick_free_port`,
`wait_for_geckodriver`, `build_capabilities`, `get_status`).
- `RoutingFetchDispatcher` mirrors `RoutingDispatcher`: same DNS-suffix
routing, no caching (each fetch spawns a fresh browser), per-route
handler name echoed in the response so the API caller knows which
path executed.
- New `POST /v1/fetch` axum route. Same Bearer-token auth and
per-domain allowlist checks as `/v1/solve`, same `record_audit` log
shape with the handler tag.
- New DTOs `FetchRequestDto` / `FetchResponseDto`.
- `AppState`/`AppStateConfig` gain a `fetch_dispatcher` field. Both
dispatchers are now built together in
`bootstrap::dispatchers::build_dispatchers`, which keeps `main.rs`
under the 200-LOC rule and lets the shared `CamoufoxPool` back both
the `Harvester` (solve) and `Fetcher` (fetch) traits via `Arc::clone`.
- Existing tests fixed up to populate the new field with an empty
`RoutingFetchDispatcher::new(None)`.
What's not wired
- No `Fetcher` impl on the Chromium pool yet — only CF-routed domains
get `/v1/fetch`. Allowlisted-but-PX-only domains return
`Conflict("no fetcher configured for host ...")`. A follow-up can
add `ChromiumoxidePool: impl Fetcher`.
- No browser session reuse — every fetch spawns geckodriver +
Camoufox + tears them down. That's ~13s on a warm machine. Pooling
is an ADR-level decision; this PR keeps the v1 surface honest.
Tests
- `cargo fmt --all -- --check`
- `cargo clippy --workspace --all-targets --all-features` against the
lefthook rule set
- `cargo test --workspace --lib` — all pre-existing tests pass plus
two new ones in `fetch_endpoint::tests`
(`routes_match_by_dns_suffix`, `returns_conflict_when_no_route_and_no_default`)
- `cargo test -p px-server` — server / pedidosya / canary integration
tests rebuild and pass after the AppStateConfig field addition
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
KeyCode17
added a commit
that referenced
this pull request
May 18, 2026
Lifts workspace.package.version 1.3.0 -> 1.4.0 and aligns the 16 internal workspace.dependencies pins in lockstep. Changes since 1.3.0: - fix(server): cache hits preserve harvester user-agent (#5) - feat(server): /v1/fetch endpoint backed by handler-owned browsers (#6) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a generic
POST /v1/fetchproxy that runs a single HTTP request from inside the same browser session a Cloudflare/PerimeterX handler would have spawned for/v1/solve. Solves the JA3/UA cliff downstream Rust clients hit when replaying the cookie bundle: the response comes from the real Firefox so cookies, TLS ClientHello, and User-Agent are all consistent to the upstream WAF.What's wired
Fetcherport trait inpx-pipelinenext toChallengeHandler, withFetchRequest/FetchResponsevalue types.CamoufoxPoolnow implementsFetcher(in addition toHarvester). The new impl navigates to the target URL's origin, waits 1.5s for CF/PX to issue cookies, then executes afetch()from the page context viaexecute_async, capturing status / headers / body.CamoufoxPool::with_session, extracted alongside the sharedcapshelpers (pick_free_port,wait_for_geckodriver,build_capabilities,get_status).RoutingFetchDispatchermirrorsRoutingDispatcher: same DNS-suffix routing, no caching (each fetch spawns a fresh browser), per-route handler name echoed in the response so the API caller knows which path executed.POST /v1/fetchaxum route. Same Bearer-token auth and per-domain allowlist checks as/v1/solve, samerecord_auditlog shape.FetchRequestDto/FetchResponseDto.AppState/AppStateConfiggain afetch_dispatcherfield. Both dispatchers are now built together inbootstrap::dispatchers::build_dispatchers, which keepsmain.rsunder the 200-LOC rule and lets the sharedCamoufoxPoolback both theHarvester(solve) andFetcher(fetch) traits viaArc::clone.RoutingFetchDispatcher::new(None).What's not wired
Fetcherimpl on the Chromium pool yet — only CF-routed domains get/v1/fetch. Allowlisted-but-PX-only domains returnConflict("no fetcher configured for host ..."). A follow-up can addChromiumoxidePool: impl Fetcher.Why
Downstream Rust HTTP clients (rustls, libcurl, wreq with stock Firefox emulation) can't replay a
cf_clearancecookie because Cloudflare's Bot Management binds the cookie to the TLS ClientHello of the browser that earned it. Camoufox's ClientHello is custom; no off-the-shelf Rust crate matches it exactly. The cleanest fix is to do the actual fetch inside the Camoufox session and return the response to the API caller, which is what/v1/fetchdoes.This unblocks consumers like the pedidosya scraper that want to drive paginated XHRs against CF-fronted endpoints without inheriting the WAF arms race.
Test plan
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-featuresagainst the lefthook rule set (-D warnings -D unwrap_used -D expect_used -D panic -D dbg_macro -D todo -D unimplemented)cargo test --workspace --lib— existing tests pass plus two new ones (routes_match_by_dns_suffix,returns_conflict_when_no_route_and_no_default) infetch_endpoint::testscargo test -p px-server—server.rsintegration tests still pass after theAppStateConfigfield additionpedidosya.com.ardeferred to a follow-up commit once a downstream consumer (the pedidosya scraper) exercises the endpoint end-to-endFiles
🤖 Generated with Claude Code