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
4 changes: 4 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions px-native/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -26,6 +27,7 @@ uuid = { workspace = true }
[dev-dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }

[lib]
name = "px_native"
118 changes: 118 additions & 0 deletions px-native/src/infrastructure/handler.rs
Original file line number Diff line number Diff line change
@@ -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<dyn NativeSolver>,
app_id: PxAppId,
name: HandlerName,
}

impl NativePxHandler {
pub fn new(solver: Arc<dyn NativeSolver>, 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<bool, AppError> {
Ok(true)
}

async fn solve(&self, page: &PageHtml) -> Result<HandlerOutcome, AppError> {
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<PxCookieBundle, AppError> {
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<dyn NativeSolver>, 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"));
}
}
4 changes: 4 additions & 0 deletions px-native/src/infrastructure/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
123 changes: 123 additions & 0 deletions px-native/src/infrastructure/native_first.rs
Original file line number Diff line number Diff line change
@@ -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<dyn ChallengeHandler>,
fallback: Arc<dyn ChallengeHandler>,
name: HandlerName,
}

impl NativeFirstHandler {
pub fn new(native: Arc<dyn ChallengeHandler>, fallback: Arc<dyn ChallengeHandler>) -> 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<bool, AppError> {
self.fallback.detects(page).await
}

async fn solve(&self, page: &PageHtml) -> Result<HandlerOutcome, AppError> {
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<bool, AppError> {
Ok(true)
}
async fn solve(&self, _page: &PageHtml) -> Result<HandlerOutcome, AppError> {
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<bool, AppError> {
Ok(true)
}
async fn solve(&self, _page: &PageHtml) -> Result<HandlerOutcome, AppError> {
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");
}
}
1 change: 1 addition & 0 deletions px-native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 2 additions & 0 deletions px-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
9 changes: 9 additions & 0 deletions px-server/src/application/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn ChallengeHandler>> {
self.routes.get(&domain.to_lowercase())
}
pub fn default_handler(&self) -> &Arc<dyn ChallengeHandler> {
&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<dyn ChallengeHandler> {
Expand Down
13 changes: 10 additions & 3 deletions px-server/src/infrastructure/bootstrap/dispatchers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,10 +25,13 @@ pub struct Dispatchers {
pub fn build_dispatchers(
default_handler: Arc<dyn ChallengeHandler>,
cf_domains: Vec<String>,
native_routes: Vec<NativeRoute>,
) -> Result<Dispatchers> {
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)),
});
}
Expand All @@ -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)),
});
}
Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions px-server/src/infrastructure/bootstrap/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod app_state;
pub mod dispatchers;
pub mod native_routes;
pub mod router;
pub mod server_metrics;
Loading
Loading