Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 109 additions & 0 deletions docs/runbook-native-bypass.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Native PerimeterX bypass — operator runbook

Status: live validation pending operator execution (ADR-0024 v1.8.0 P4).

This is the end-to-end procedure for taking the native PX path from
"compiled and unit-tested" to "sustaining ≥40 req/min through a real
tenant". It assumes:

- A working AR (or other tenant-appropriate) residential proxy is
available — set `PX_PROXIES=socks5://…` in your shell.
- Camoufox + geckodriver are installed and `CamoufoxConfig::from_env()`
resolves them.
- You have the `eT15wiaE` (pedidosya) profile at
`px-native/profiles/eT15wiaE.toml`.

## Step 1 — Capture ground truth

Run the XHR-hook capture against the live target. This drives a real
Firefox/Camoufox session through the proxy and records every
plaintext sensor event the runtime feeds into the encryptor:

```bash
CAPTURE_PX=1 \
CAPTURE_URL=https://www.pedidosya.com.ar/ \
CAPTURE_WAIT_MS=15000 \
PX_PROXIES="$PX_PROXIES" \
cargo test -q -p pxsolver-camoufox --test capture_sensor -- --ignored --nocapture
```

Output lands at `px-research/captures/eT15wiaE/<unix-ts>.json`:

- `plaintext_events` — every `[{t, d}, …]` batch JSON-stringified by
the page (= the input to the cipher);
- `xhr_sends` — every `/b/s` request URL + body (= what hit the wire);
- `cookies` — the cookie jar at end of the wait window;
- `user_agent`.

Repeat the capture 3–5 times. Tag variation across captures helps
distinguish stable fields from per-session noise.

## Step 2 — Calibrate

For each capture, diff against `default_batch`:

```bash
px-cli calibrate px-research/captures/eT15wiaE/<unix-ts>.json
```

Output identifies:

- **Missing tags** the runtime emits but `default_batch` does not.
- **Extra tags** we emit but the runtime did not (those probably tank
the trust score — drop them).
- **Per-tag missing keys** — base64-veiled field names we still need
to populate.

Iterate `px-native/src/events/batch.rs` until the report shows no
missing tags or keys for the eT15wiaE tenant.

## Step 3 — Enable the native overlay

```bash
export PX_NATIVE_PROFILES="pedidosya.com.ar=px-native/profiles/eT15wiaE.toml"
cargo run -p px-server # logs: "PX_NATIVE_PROFILES → native overlay enabled"
```

The dispatcher will try the native handler first for any solve
targeting `pedidosya.com.ar` and fall back to the existing Camoufox
path on error or non-solved status.

## Step 4 — Throughput soak

```bash
NATIVE_SOAK=1 \
NATIVE_SOAK_URL=https://www.pedidosya.com.ar/ \
NATIVE_SOAK_N=80 \
NATIVE_SOAK_CONCURRENCY=8 \
NATIVE_SOAK_TARGET_RPM=40 \
NATIVE_SOAK_PROFILE=px-native/profiles/eT15wiaE.toml \
cargo test -q -p pxsolver-native --test throughput_soak -- --ignored --nocapture
```

The soak runs `SensorNativeSolver::solve` 80× through your live proxy
and asserts ≥40 req/min sustained throughput. Output:

```
=== NATIVE_SOAK ===
n: 80
ok: <count>
err: <count>
success_rate: <%>
elapsed: <duration>
rpm: <req/min>
p50: <ms>
p95: <ms>
```

If the assertion fails on `success_rate`, calibration in step 2
needs another iteration. If it fails on `rpm` only, the cipher
correctness is fine but the proxy/concurrency setup needs tuning.

## Step 5 — Promote the soak

Once a green soak run sustains ≥40 req/min for at least three
consecutive runs across different proxies / time-of-day, freeze
the profile and document the result in
`docs/verification/<date>-pedidosya-native-soak.md`. Open the
follow-up ADR proposing the profile schema lock + tenant
expansion plan (other PX tenants).
1 change: 1 addition & 0 deletions px-native/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ url = { workspace = true }
uuid = { workspace = true }

[dev-dependencies]
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
Expand Down
128 changes: 128 additions & 0 deletions px-native/tests/throughput_soak.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
//! v1.8.0/P4 — live throughput soak for the native PX solver.
//!
//! Drives `SensorNativeSolver::solve` N times in parallel against a
//! live PX target and measures:
//! * solve success rate (Ok / total)
//! * sustained req/min
//! * p50 / p95 latency
//!
//! Writes a markdown evidence file. Asserts the configurable target
//! (default 40 req/min); the bet from ADR-0024 is that native sensor
//! synthesis can sustain ≥40 req/min through `/v1/solve` once
//! `default_batch` is calibrated against ground-truth captures.
//!
//! Run with:
//! NATIVE_SOAK=1 \
//! [NATIVE_SOAK_URL=https://www.pedidosya.com.ar/] \
//! [NATIVE_SOAK_N=80] \
//! [NATIVE_SOAK_CONCURRENCY=8] \
//! [NATIVE_SOAK_TARGET_RPM=40] \
//! [NATIVE_SOAK_PROFILE=px-native/profiles/eT15wiaE.toml] \
//! cargo test -p pxsolver-native --test throughput_soak -- --ignored --nocapture

use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;

use futures::stream::{FuturesUnordered, StreamExt};
use px_core::{Fingerprint, PxAppId};
use px_native::profile::TenantProfile;
use px_native::{NativeSolver, SensorNativeSolver, SolveContext};
use reqwest::Client;

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn native_throughput_soak() {
if std::env::var("NATIVE_SOAK").ok().as_deref() != Some("1") {
eprintln!("set NATIVE_SOAK=1 to run");
return;
}
let url =
std::env::var("NATIVE_SOAK_URL").unwrap_or_else(|_| "https://www.pedidosya.com.ar/".into());
let n: usize = std::env::var("NATIVE_SOAK_N")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(80);
let concurrency: usize = std::env::var("NATIVE_SOAK_CONCURRENCY")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8);
let target_rpm: f64 = std::env::var("NATIVE_SOAK_TARGET_RPM")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(40.0);
let profile_path: PathBuf = std::env::var("NATIVE_SOAK_PROFILE")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("profiles/eT15wiaE.toml"));

let profile = TenantProfile::load(&profile_path).expect("load profile");
let app_id = PxAppId::new(&profile.app_id).expect("valid app id");
let client = Client::builder().build().expect("client");
let solver: Arc<dyn NativeSolver> =
Arc::new(SensorNativeSolver::new(client, Arc::new(profile)));
let ctx_template = SolveContext::new(url.clone(), app_id.clone(), soak_fingerprint());

eprintln!("soak: n={n} concurrency={concurrency} target_rpm={target_rpm} url={url}");

let mut latencies_ms: Vec<u128> = Vec::with_capacity(n);
let mut ok_count: usize = 0;
let mut err_count: usize = 0;
let started = Instant::now();

let mut tasks: FuturesUnordered<_> = FuturesUnordered::new();
let mut launched = 0usize;
while launched < n || !tasks.is_empty() {
while launched < n && tasks.len() < concurrency {
let solver = Arc::clone(&solver);
let ctx = ctx_template.clone();
tasks.push(tokio::spawn(async move {
let t0 = Instant::now();
let outcome = solver.solve(&ctx).await;
(t0.elapsed(), outcome)
}));
launched += 1;
}
if let Some(res) = tasks.next().await {
let (elapsed, outcome) = res.expect("join task");
latencies_ms.push(elapsed.as_millis());
match outcome {
Ok(_) => ok_count += 1,
Err(e) => {
err_count += 1;
eprintln!("solve err: {e}");
}
}
}
}
let total_elapsed = started.elapsed();
let rpm = (n as f64) / total_elapsed.as_secs_f64() * 60.0;
let success_rate = (ok_count as f64) / (n as f64) * 100.0;
latencies_ms.sort_unstable();
let p50 = latencies_ms[latencies_ms.len() / 2];
let p95 = latencies_ms[(latencies_ms.len() * 95 / 100).min(latencies_ms.len() - 1)];

eprintln!(
"\n=== NATIVE_SOAK ===\n n: {n}\n ok: {ok_count}\n err: {err_count}\n success_rate: {success_rate:.1}%\n elapsed: {:?}\n rpm: {rpm:.1}\n p50: {p50} ms\n p95: {p95} ms",
total_elapsed
);

assert!(
rpm >= target_rpm,
"throughput {rpm:.1} req/min below target {target_rpm:.1}"
);
}

fn soak_fingerprint() -> Fingerprint {
Fingerprint {
user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0".into(),
accept_language: vec!["es-AR".into(), "es".into(), "en-US".into()],
screen_width: 1366,
screen_height: 768,
device_pixel_ratio: 1,
timezone: "America/Argentina/Buenos_Aires".into(),
platform: "Linux x86_64".into(),
webgl_vendor: "Mozilla".into(),
webgl_renderer: "Mozilla".into(),
}
}
Loading