From 22ec1cf13542a3856c6038440a6b9387da652c7c Mon Sep 17 00:00:00 2001 From: KeyCode17 Date: Wed, 20 May 2026 03:10:17 +0700 Subject: [PATCH] feat(server): wire SensorNativeSolver into solve dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0024 v1.8.0 P1. Adds a `PX_NATIVE_PROFILES` env loader that parses `domain=path/to/profile.toml` pairs and decorates the existing per-domain solve handler with a `NativeFirstHandler` (native first, fall back to Camoufox/PerimeterxHandler on error or non-solved status). px-native: - `infrastructure/handler.rs` — `NativePxHandler` wraps a `SensorNativeSolver` as a `ChallengeHandler`. Default fingerprint embedded for now; real fingerprints flow in from the SolveContext once the synthetic-user pool is wired (v1.9+). - `infrastructure/native_first.rs` — `NativeFirstHandler` decorator. - `lib.rs` re-exports `SensorNativeSolver`. - Cargo: depends on `px-pipeline`; adds `tokio` as a dev-dep. px-server: - `bootstrap/native_routes.rs` — `parse_native_routes` + overlay. - `bootstrap/dispatchers.rs` — `build_dispatchers` takes a third arg `Vec` and applies the overlay regardless of CF route presence. - `application/routing.rs` — exposes `handler_for(domain)` and `default_handler()` so overlays can wrap the existing route. - `main.rs` reads `PX_NATIVE_PROFILES` at startup. Wiring is conservative — operator must opt-in via env. Camoufox path is untouched. 129 workspace tests pass; clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 4 + px-native/Cargo.toml | 2 + px-native/src/infrastructure/handler.rs | 118 +++++++++++++++++ px-native/src/infrastructure/mod.rs | 4 + px-native/src/infrastructure/native_first.rs | 123 ++++++++++++++++++ px-native/src/lib.rs | 1 + px-server/Cargo.toml | 2 + px-server/src/application/routing.rs | 9 ++ .../infrastructure/bootstrap/dispatchers.rs | 13 +- px-server/src/infrastructure/bootstrap/mod.rs | 1 + .../infrastructure/bootstrap/native_routes.rs | 109 ++++++++++++++++ px-server/src/main.rs | 8 +- 12 files changed, 390 insertions(+), 4 deletions(-) create mode 100644 px-native/src/infrastructure/handler.rs create mode 100644 px-native/src/infrastructure/native_first.rs create mode 100644 px-server/src/infrastructure/bootstrap/native_routes.rs diff --git a/Cargo.lock b/Cargo.lock index 2e9f656..6eb1d80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1497,9 +1497,11 @@ dependencies = [ "pxsolver-core", "pxsolver-errors", "pxsolver-harvester", + "pxsolver-native", "pxsolver-perimeterx", "pxsolver-pipeline", "pxsolver-types", + "reqwest 0.12.28", "serde", "serde_json", "tokio", @@ -1632,9 +1634,11 @@ dependencies = [ "async-trait", "pxsolver-core", "pxsolver-errors", + "pxsolver-pipeline", "reqwest 0.12.28", "serde", "serde_json", + "tokio", "toml", "tracing", "url", diff --git a/px-native/Cargo.toml b/px-native/Cargo.toml index c6348a5..d7c076e 100644 --- a/px-native/Cargo.toml +++ b/px-native/Cargo.toml @@ -15,6 +15,7 @@ workspace = true async-trait = { workspace = true } px-core = { workspace = true } px-errors = { workspace = true } +px-pipeline = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -26,6 +27,7 @@ uuid = { workspace = true } [dev-dependencies] serde = { workspace = true } serde_json = { workspace = true } +tokio = { workspace = true } [lib] name = "px_native" diff --git a/px-native/src/infrastructure/handler.rs b/px-native/src/infrastructure/handler.rs new file mode 100644 index 0000000..ccb9fb1 --- /dev/null +++ b/px-native/src/infrastructure/handler.rs @@ -0,0 +1,118 @@ +//! `ChallengeHandler` adapter for [`SensorNativeSolver`] so it can slot +//! into the existing routing dispatcher. Pair this with +//! [`super::native_first::NativeFirstHandler`] to get the +//! "native first, browser on failure" wiring. + +use std::sync::Arc; +use std::time::Instant; + +use async_trait::async_trait; +use px_core::{CookieJarDelta, Fingerprint, PxAppId}; +use px_errors::AppError; +use px_pipeline::{ChallengeHandler, HandlerMetrics, HandlerName, HandlerOutcome, PageHtml}; + +use crate::domain::native_solver::{NativeSolver, SolveContext}; + +pub struct NativePxHandler { + solver: Arc, + app_id: PxAppId, + name: HandlerName, +} + +impl NativePxHandler { + pub fn new(solver: Arc, app_id: PxAppId) -> Self { + Self { + solver, + app_id, + name: "perimeterx-native", + } + } +} + +#[async_trait] +impl ChallengeHandler for NativePxHandler { + fn name(&self) -> HandlerName { + self.name + } + + async fn detects(&self, _page: &PageHtml) -> Result { + Ok(true) + } + + async fn solve(&self, page: &PageHtml) -> Result { + let started = Instant::now(); + let ctx = SolveContext::new(page.url.clone(), self.app_id.clone(), default_fingerprint()); + let bundle = self.solver.solve(&ctx).await?; + let metrics = HandlerMetrics { + solve_ms: started.elapsed().as_millis() as u64, + ..Default::default() + }; + Ok(HandlerOutcome::solved_with_ua( + self.name, + CookieJarDelta { + set: bundle.cookies, + removed: Vec::new(), + }, + Vec::new(), + metrics, + bundle.user_agent, + )) + } +} + +fn default_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(), + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + use px_core::{NamedCookie, PxCookieBundle}; + use px_pipeline::HandlerStatus; + use std::time::{Duration, SystemTime}; + + struct AlwaysOkSolver; + + #[async_trait] + impl NativeSolver for AlwaysOkSolver { + async fn solve(&self, _ctx: &SolveContext) -> Result { + Ok(PxCookieBundle::new( + vec![NamedCookie { + name: "_px3".into(), + value: "native".into(), + domain: "example.com".into(), + path: "/".into(), + }], + "ua", + SystemTime::now(), + Duration::from_secs(60), + )) + } + } + + fn app_id() -> PxAppId { + PxAppId::new("PXeT15wiaE").expect("valid app id") + } + + #[tokio::test] + async fn handler_reports_solved_status() { + let handler = + NativePxHandler::new(Arc::new(AlwaysOkSolver) as Arc, app_id()); + let page = PageHtml::new("https://www.pedidosya.com.ar/", ""); + let out = handler.solve(&page).await.expect("solve"); + assert_eq!(out.status, HandlerStatus::Solved); + assert_eq!(out.cookies.set.len(), 1); + assert_eq!(out.user_agent.as_deref(), Some("ua")); + } +} diff --git a/px-native/src/infrastructure/mod.rs b/px-native/src/infrastructure/mod.rs index 0a95307..e03dea1 100644 --- a/px-native/src/infrastructure/mod.rs +++ b/px-native/src/infrastructure/mod.rs @@ -1,5 +1,9 @@ pub mod cookies; +pub mod handler; +pub mod native_first; pub mod not_implemented; pub mod sensor_solver; +pub use handler::NativePxHandler; +pub use native_first::NativeFirstHandler; pub use sensor_solver::SensorNativeSolver; diff --git a/px-native/src/infrastructure/native_first.rs b/px-native/src/infrastructure/native_first.rs new file mode 100644 index 0000000..3280019 --- /dev/null +++ b/px-native/src/infrastructure/native_first.rs @@ -0,0 +1,123 @@ +//! `NativeFirstHandler` — decorator that tries a native handler and +//! falls back to a browser-based one on error. + +use std::sync::Arc; + +use async_trait::async_trait; +use px_errors::AppError; +use px_pipeline::{ChallengeHandler, HandlerName, HandlerOutcome, HandlerStatus, PageHtml}; + +pub struct NativeFirstHandler { + native: Arc, + fallback: Arc, + name: HandlerName, +} + +impl NativeFirstHandler { + pub fn new(native: Arc, fallback: Arc) -> Self { + Self { + native, + fallback, + name: "perimeterx-native-first", + } + } +} + +#[async_trait] +impl ChallengeHandler for NativeFirstHandler { + fn name(&self) -> HandlerName { + self.name + } + + async fn detects(&self, page: &PageHtml) -> Result { + self.fallback.detects(page).await + } + + async fn solve(&self, page: &PageHtml) -> Result { + match self.native.solve(page).await { + Ok(out) if matches!(out.status, HandlerStatus::Solved) => Ok(out), + Ok(out) => { + tracing::info!( + target: "px_native", + status = ?out.status, + "native handler not solved, falling back" + ); + self.fallback.solve(page).await + } + Err(e) => { + tracing::warn!( + target: "px_native", + error = %e, + "native handler error, falling back" + ); + self.fallback.solve(page).await + } + } + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + use px_core::CookieJarDelta; + use px_pipeline::HandlerMetrics; + + struct SolvedHandler(&'static str); + struct FailingHandler; + + #[async_trait] + impl ChallengeHandler for SolvedHandler { + fn name(&self) -> HandlerName { + self.0 + } + async fn detects(&self, _page: &PageHtml) -> Result { + Ok(true) + } + async fn solve(&self, _page: &PageHtml) -> Result { + Ok(HandlerOutcome::solved_with_ua( + self.0, + CookieJarDelta::default(), + Vec::new(), + HandlerMetrics::default(), + "ua", + )) + } + } + + #[async_trait] + impl ChallengeHandler for FailingHandler { + fn name(&self) -> HandlerName { + "failing" + } + async fn detects(&self, _page: &PageHtml) -> Result { + Ok(true) + } + async fn solve(&self, _page: &PageHtml) -> Result { + Err(AppError::InternalError("synthetic".into())) + } + } + + #[tokio::test] + async fn prefers_native_when_ok() { + let h = NativeFirstHandler::new( + Arc::new(SolvedHandler("native")), + Arc::new(SolvedHandler("fallback")), + ); + let out = h + .solve(&PageHtml::new("https://x/", "")) + .await + .expect("solve"); + assert_eq!(out.handler, "native"); + } + + #[tokio::test] + async fn falls_back_on_error() { + let h = NativeFirstHandler::new(Arc::new(FailingHandler), Arc::new(SolvedHandler("fb"))); + let out = h + .solve(&PageHtml::new("https://x/", "")) + .await + .expect("solve"); + assert_eq!(out.handler, "fb"); + } +} diff --git a/px-native/src/lib.rs b/px-native/src/lib.rs index f29d4c0..c61a0ef 100644 --- a/px-native/src/lib.rs +++ b/px-native/src/lib.rs @@ -6,3 +6,4 @@ pub mod profile; pub use domain::native_solver::{NativeSolver, SolveContext}; pub use infrastructure::not_implemented::NotImplementedNativeSolver; +pub use infrastructure::sensor_solver::SensorNativeSolver; diff --git a/px-server/Cargo.toml b/px-server/Cargo.toml index 38305c4..d2776b4 100644 --- a/px-server/Cargo.toml +++ b/px-server/Cargo.toml @@ -30,7 +30,9 @@ px-cloudflare = { workspace = true } px-core = { workspace = true } px-errors = { workspace = true } px-harvester = { workspace = true } +px-native = { workspace = true } px-perimeterx = { workspace = true } +reqwest = { workspace = true } px-pipeline = { workspace = true } px-types = { workspace = true } serde = { workspace = true } diff --git a/px-server/src/application/routing.rs b/px-server/src/application/routing.rs index 8b32de1..e75e605 100644 --- a/px-server/src/application/routing.rs +++ b/px-server/src/application/routing.rs @@ -44,6 +44,15 @@ impl RoutingDispatcher { self } + /// Exact-key route lookup. Overlays wrap the result in a + /// decorator and re-register it. + pub fn handler_for(&self, domain: &str) -> Option<&Arc> { + self.routes.get(&domain.to_lowercase()) + } + pub fn default_handler(&self) -> &Arc { + &self.default + } + /// Look up the handler matched by `host`. Matching is DNS-suffix: /// `pedidosya.com.ar` matches host `www.pedidosya.com.ar`. fn resolve(&self, host: &str) -> &Arc { diff --git a/px-server/src/infrastructure/bootstrap/dispatchers.rs b/px-server/src/infrastructure/bootstrap/dispatchers.rs index b6766b9..22491a8 100644 --- a/px-server/src/infrastructure/bootstrap/dispatchers.rs +++ b/px-server/src/infrastructure/bootstrap/dispatchers.rs @@ -8,7 +8,8 @@ use crate::application::fetch_endpoint::{FetchDispatcher, RoutingFetchDispatcher}; use crate::application::routing::RoutingDispatcher; -use crate::application::solve_endpoint::{PxSolveDispatcher, SolveDispatcher}; +use crate::application::solve_endpoint::SolveDispatcher; +use crate::infrastructure::bootstrap::native_routes::{NativeRoute, apply_native_overlay}; use anyhow::{Context, Result}; use px_camoufox::{CamoufoxConfig, CamoufoxPool}; use px_cloudflare::CloudflareHandler; @@ -24,10 +25,13 @@ pub struct Dispatchers { pub fn build_dispatchers( default_handler: Arc, cf_domains: Vec, + native_routes: Vec, ) -> Result { if cf_domains.is_empty() { + let mut router = RoutingDispatcher::new(default_handler); + router = apply_native_overlay(router, native_routes)?; return Ok(Dispatchers { - solve: Arc::new(PxSolveDispatcher::new(default_handler)), + solve: Arc::new(router), fetch: Arc::new(RoutingFetchDispatcher::new(None)), }); } @@ -39,8 +43,10 @@ pub fn build_dispatchers( domains = ?cf_domains, "Cloudflare routes configured but Camoufox unavailable; falling back to Chromium-only solve dispatcher (no /v1/fetch)" ); + let mut router = RoutingDispatcher::new(default_handler); + router = apply_native_overlay(router, native_routes)?; return Ok(Dispatchers { - solve: Arc::new(PxSolveDispatcher::new(default_handler)), + solve: Arc::new(router), fetch: Arc::new(RoutingFetchDispatcher::new(None)), }); } @@ -58,6 +64,7 @@ pub fn build_dispatchers( fetch_router = fetch_router.with_route(d.clone(), "cloudflare", Arc::clone(&fetcher)); } tracing::info!(domains = ?cf_domains, "Camoufox routing enabled (solve + fetch)"); + solve_router = apply_native_overlay(solve_router, native_routes)?; Ok(Dispatchers { solve: Arc::new(solve_router), fetch: Arc::new(fetch_router), diff --git a/px-server/src/infrastructure/bootstrap/mod.rs b/px-server/src/infrastructure/bootstrap/mod.rs index fb7151e..2077dda 100644 --- a/px-server/src/infrastructure/bootstrap/mod.rs +++ b/px-server/src/infrastructure/bootstrap/mod.rs @@ -1,4 +1,5 @@ pub mod app_state; pub mod dispatchers; +pub mod native_routes; pub mod router; pub mod server_metrics; diff --git a/px-server/src/infrastructure/bootstrap/native_routes.rs b/px-server/src/infrastructure/bootstrap/native_routes.rs new file mode 100644 index 0000000..a53fbf2 --- /dev/null +++ b/px-server/src/infrastructure/bootstrap/native_routes.rs @@ -0,0 +1,109 @@ +//! Wire `SensorNativeSolver` into the solve dispatcher (ADR-0024, v1.8.0). +//! +//! Routes are loaded from `PX_NATIVE_PROFILES`, a comma-separated list of +//! `domain=path/to/profile.toml` pairs. For each entry, we instantiate a +//! [`SensorNativeSolver`] from the profile and decorate the existing +//! per-domain handler with [`NativeFirstHandler`] so the native path is +//! tried first and the Camoufox/Chromium harvester remains the fallback. + +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use px_core::PxAppId; +use px_native::infrastructure::{NativeFirstHandler, NativePxHandler}; +use px_native::profile::TenantProfile; +use px_native::{NativeSolver, SensorNativeSolver}; +use px_pipeline::ChallengeHandler; +use reqwest::Client; + +use crate::application::routing::RoutingDispatcher; + +pub struct NativeRoute { + pub domain: String, + pub profile_path: PathBuf, +} + +pub fn parse_native_routes(raw: Option<&str>) -> Vec { + raw.unwrap_or("") + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .filter_map(|spec| { + let (domain, path) = spec.split_once('=')?; + Some(NativeRoute { + domain: domain.trim().to_lowercase(), + profile_path: PathBuf::from(path.trim()), + }) + }) + .collect() +} + +pub fn apply_native_overlay( + mut router: RoutingDispatcher, + routes: Vec, +) -> Result { + if routes.is_empty() { + return Ok(router); + } + let client = Client::builder() + .build() + .context("build reqwest client for native handler")?; + for route in routes { + let profile = TenantProfile::load(&route.profile_path).with_context(|| { + format!( + "load tenant profile {} for {}", + route.profile_path.display(), + route.domain + ) + })?; + let app_id = PxAppId::new(&profile.app_id).map_err(|e| { + anyhow::anyhow!( + "profile {} has invalid app_id: {e}", + route.profile_path.display() + ) + })?; + let solver: Arc = + Arc::new(SensorNativeSolver::new(client.clone(), Arc::new(profile))); + let native: Arc = Arc::new(NativePxHandler::new(solver, app_id)); + let fallback = router + .handler_for(&route.domain) + .cloned() + .unwrap_or_else(|| Arc::clone(router.default_handler())); + let wrapped: Arc = + Arc::new(NativeFirstHandler::new(native, fallback)); + router = router.with_route(route.domain.clone(), wrapped); + tracing::info!(domain = %route.domain, "native PX solver overlaid (native-first)"); + } + Ok(router) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn parse_csv_pairs() { + let r = parse_native_routes(Some( + " pedidosya.com.ar=profiles/eT15wiaE.toml , foo.com=/x.toml ", + )); + assert_eq!(r.len(), 2); + assert_eq!(r[0].domain, "pedidosya.com.ar"); + assert_eq!(r[0].profile_path, PathBuf::from("profiles/eT15wiaE.toml")); + assert_eq!(r[1].domain, "foo.com"); + } + + #[test] + fn parse_unset_is_empty() { + assert!(parse_native_routes(None).is_empty()); + assert!(parse_native_routes(Some("")).is_empty()); + } + + #[test] + fn ignores_malformed_entries() { + let r = parse_native_routes(Some("nopair, ok=p")); + assert_eq!(r.len(), 1); + assert_eq!(r[0].domain, "ok"); + } +} diff --git a/px-server/src/main.rs b/px-server/src/main.rs index 8e90304..6ef99e9 100644 --- a/px-server/src/main.rs +++ b/px-server/src/main.rs @@ -8,6 +8,7 @@ use px_perimeterx::PerimeterxHandler; use px_pipeline::ChallengeHandler; use px_server::application::routing::parse_camoufox_domains; use px_server::infrastructure::bootstrap::dispatchers::build_dispatchers; +use px_server::infrastructure::bootstrap::native_routes::parse_native_routes; use px_server::{AppState, AppStateConfig, build_router}; use std::collections::BTreeSet; use std::env; @@ -41,7 +42,12 @@ async fn main() -> Result<()> { Arc::new(PerimeterxHandler::new(Arc::clone(&harvester))); let cf_domains = resolve_cf_domains(allowlist_store.as_ref()).await?; - let dispatchers = build_dispatchers(px_handler, cf_domains)?; + let native_routes = parse_native_routes(env::var("PX_NATIVE_PROFILES").ok().as_deref()); + if !native_routes.is_empty() { + let domains: Vec<&str> = native_routes.iter().map(|r| r.domain.as_str()).collect(); + tracing::info!(?domains, "PX_NATIVE_PROFILES → native overlay enabled"); + } + let dispatchers = build_dispatchers(px_handler, cf_domains, native_routes)?; let state = AppState::new(AppStateConfig { verify_key: Arc::new(VerifyKey::new(Arc::new(key_store))),