From 20c635eb0b4efb3d2d45b889b0123ff7ebcaeec8 Mon Sep 17 00:00:00 2001 From: glendc Date: Wed, 21 Jan 2026 22:38:12 +0100 Subject: [PATCH 01/52] initial netbench cli code --- Cargo.lock | 14 ++++ Cargo.toml | 2 +- docs/netbench.md | 3 + justfile | 6 ++ netbench/Cargo.toml | 26 +++++++ netbench/src/cmd/mock/mod.rs | 10 +++ netbench/src/cmd/mod.rs | 2 + netbench/src/cmd/run/mod.rs | 13 ++++ netbench/src/main.rs | 120 ++++++++++++++++++++++++++++++++ netbench/src/utils/mod.rs | 1 + netbench/src/utils/telemetry.rs | 56 +++++++++++++++ 11 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 docs/netbench.md create mode 100644 netbench/Cargo.toml create mode 100644 netbench/src/cmd/mock/mod.rs create mode 100644 netbench/src/cmd/mod.rs create mode 100644 netbench/src/cmd/run/mod.rs create mode 100644 netbench/src/main.rs create mode 100644 netbench/src/utils/mod.rs create mode 100644 netbench/src/utils/telemetry.rs diff --git a/Cargo.lock b/Cargo.lock index 614b3e9a..34600692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1609,6 +1609,20 @@ dependencies = [ "syn", ] +[[package]] +name = "netbench" +version = "0.1.0" +dependencies = [ + "clap", + "mimalloc", + "rama", + "rand", + "serde", + "serde_json", + "tikv-jemallocator", + "tokio", +] + [[package]] name = "nibble_vec" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 604df2af..718b33de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["proxy"] +members = ["netbench", "proxy"] resolver = "3" [workspace.dependencies.rama] diff --git a/docs/netbench.md b/docs/netbench.md new file mode 100644 index 00000000..19c608ec --- /dev/null +++ b/docs/netbench.md @@ -0,0 +1,3 @@ +# Network Benchmarker (netbench) + +TODO diff --git a/justfile b/justfile index 864b3f8d..bf11204b 100644 --- a/justfile +++ b/justfile @@ -29,6 +29,12 @@ run-proxy *ARGS: --pretty \ {{ARGS}} +run-netbench *ARGS: + cargo run \ + --bin netbench \ + {{ARGS}} + + proxy-har-toggle: curl -v -XPOST http://127.0.0.1:8088/har/toggle diff --git a/netbench/Cargo.toml b/netbench/Cargo.toml new file mode 100644 index 00000000..67f22171 --- /dev/null +++ b/netbench/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "netbench" +description = "Network Benchmark tool in function of safechain-proxy" +version = "0.1.0" +edition = "2024" +publish = false +rust-version = "1.91" +readme = "../docs/netbench.md" +resolver = "3" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +tokio = { version = "1.48", features = ["full"] } +serde = "1.0" +serde_json = "1" +rand = "0.9" + +[target.'cfg(target_family = "unix")'.dependencies] +jemallocator = { package = "tikv-jemallocator", version = "0.6" } + +[target.'cfg(target_os = "windows")'.dependencies] +mimalloc = { version = "0.1", default-features = false } + +[dependencies.rama] +workspace = true +features = ["http-full", "proxy-full", "dns", "boring"] diff --git a/netbench/src/cmd/mock/mod.rs b/netbench/src/cmd/mock/mod.rs new file mode 100644 index 00000000..3b939ebd --- /dev/null +++ b/netbench/src/cmd/mock/mod.rs @@ -0,0 +1,10 @@ +use clap::Args; +use rama::error::OpaqueError; + +#[derive(Debug, Clone, Args)] +/// run bench mock server +pub struct MockCommand {} + +pub async fn exec(_args: MockCommand) -> Result<(), OpaqueError> { + Ok(()) +} diff --git a/netbench/src/cmd/mod.rs b/netbench/src/cmd/mod.rs new file mode 100644 index 00000000..d9276831 --- /dev/null +++ b/netbench/src/cmd/mod.rs @@ -0,0 +1,2 @@ +pub mod mock; +pub mod run; diff --git a/netbench/src/cmd/run/mod.rs b/netbench/src/cmd/run/mod.rs new file mode 100644 index 00000000..4aab841e --- /dev/null +++ b/netbench/src/cmd/run/mod.rs @@ -0,0 +1,13 @@ +use std::time::Duration; + +use clap::Args; +use rama::error::OpaqueError; + +#[derive(Debug, Clone, Args)] +/// run benhmarker +pub struct RunCommand {} + +pub async fn exec(_args: RunCommand) -> Result<(), OpaqueError> { + tokio::time::sleep(Duration::from_secs(5)).await; + Ok(()) +} diff --git a/netbench/src/main.rs b/netbench/src/main.rs new file mode 100644 index 00000000..e06b55c8 --- /dev/null +++ b/netbench/src/main.rs @@ -0,0 +1,120 @@ +use std::{path::PathBuf, time::Duration}; + +use rama::{ + error::{BoxError, OpaqueError}, + graceful, + telemetry::tracing, +}; + +use clap::{Parser, Subcommand}; + +pub mod cmd; +pub mod utils; + +#[cfg(target_family = "unix")] +#[global_allocator] +static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc; + +#[cfg(target_os = "windows")] +#[global_allocator] +static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; + +/// CLI arguments for configuring netbench behavior. +#[derive(Debug, Clone, Parser)] +#[command(name = " netbench")] +#[command(bin_name = "netbench")] +#[command(version, about, long_about = None)] +pub struct Args { + #[command(subcommand)] + cmds: CliCommands, + + /// debug logging as default instead of Info; use RUST_LOG env for more options + #[arg(long, short = 'v', default_value_t = false, global = true)] + pub verbose: bool, + + /// enable pretty logging (format for humans) + #[arg(long, default_value_t = false, global = true)] + pub pretty: bool, + + /// write the tracing output to the provided (log) file instead of stderr + #[arg(long, short = 'o', global = true)] + pub output: Option, + + #[arg(long, value_name = "SECONDS", default_value_t = 1., global = true)] + /// the graceful shutdown timeout (<= 0.0 = no timeout) + pub graceful: f64, +} + +#[derive(Debug, Clone, Subcommand)] +#[allow(clippy::large_enum_variant)] +enum CliCommands { + Run(self::cmd::run::RunCommand), + Mock(self::cmd::mock::MockCommand), +} + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + let args = Args::parse(); + + self::utils::telemetry::init_tracing(&args)?; + + let base_shutdown_signal = graceful::default_signal(); + if let Err(err) = run_with_args(base_shutdown_signal, args).await { + eprintln!("🚩 exit with error: {err}"); + std::process::exit(1); + } + + Ok(()) +} + +/// run a netbench cmd with the given args +async fn run_with_args(base_shutdown_signal: F, args: Args) -> Result<(), BoxError> +where + F: Future + Send + 'static, +{ + let graceful_timeout = (args.graceful > 0.).then(|| Duration::from_secs_f64(args.graceful)); + + let (error_tx, error_rx) = tokio::sync::oneshot::channel::(); + let graceful = graceful::Shutdown::new(new_shutdown_signal(error_rx, base_shutdown_signal)); + + graceful.spawn_task(async move { + let result = match args.cmds { + CliCommands::Run(args) => self::cmd::run::exec(args).await, + CliCommands::Mock(args) => self::cmd::mock::exec(args).await, + }; + if let Err(err) = result { + let _ = error_tx.send(err); + } + }); + + let delay = match graceful_timeout { + Some(duration) => graceful.shutdown_with_limit(duration).await?, + None => graceful.shutdown().await, + }; + + tracing::debug!("gracefully shutdown with a delay of: {delay:?}"); + Ok(()) +} + +fn new_shutdown_signal( + error_rx: tokio::sync::oneshot::Receiver, + base_shutdown_signal: impl Future + Send + 'static, +) -> impl Future + Send + 'static { + async move { + tokio::select! { + _ = base_shutdown_signal => { + tracing::debug!("default signal triggered: init graceful shutdown"); + } + result = error_rx => { + match result { + Ok(err) => { + tracing::error!("fatal err received: {err}; abort"); + }, + Err(_) => { + tracing::debug!("command is finished without error, return control"); + }, + } + } + } + } +} diff --git a/netbench/src/utils/mod.rs b/netbench/src/utils/mod.rs new file mode 100644 index 00000000..304af1e0 --- /dev/null +++ b/netbench/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod telemetry; diff --git a/netbench/src/utils/telemetry.rs b/netbench/src/utils/telemetry.rs new file mode 100644 index 00000000..d209ea2d --- /dev/null +++ b/netbench/src/utils/telemetry.rs @@ -0,0 +1,56 @@ +use std::io::IsTerminal as _; + +use rama::{ + error::{BoxError, ErrorContext as _}, + telemetry::tracing::{ + self, + metadata::LevelFilter, + subscriber::{EnvFilter, fmt::writer::BoxMakeWriter}, + }, +}; + +use crate::Args; + +/// Configures structured logging with runtime control via `RUST_LOG` environment variable. +/// +/// Defaults to INFO level to balance visibility with performance. +/// Use `RUST_LOG=debug` or `RUST_LOG=trace` for troubleshooting. +pub fn init_tracing(args: &Args) -> Result<(), BoxError> { + let directive = if args.verbose { + LevelFilter::DEBUG + } else { + LevelFilter::INFO + } + .into(); + + let make_writer = match args.output.as_deref() { + Some(path) => { + let file = std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(path) + .context("open log file")?; + + BoxMakeWriter::new(file) + } + None => BoxMakeWriter::new(std::io::stderr), + }; + + let subscriber = tracing::subscriber::fmt() + .with_ansi(args.output.is_none() && std::io::stderr().is_terminal()) + .with_env_filter( + EnvFilter::builder() + .with_default_directive(directive) + .from_env_lossy(), + ) + .with_writer(make_writer); + + if args.pretty { + subscriber.pretty().try_init()?; + } else { + subscriber.try_init()?; + } + + tracing::info!("Tracing is set up"); + Ok(()) +} From 5241116268ef52676d4e41b337ac3dd82da694d7 Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 22 Jan 2026 15:28:54 +0100 Subject: [PATCH 02/52] generate client requests --- Cargo.lock | 164 ---------------- netbench/Cargo.toml | 1 - netbench/src/cmd/mock/mod.rs | 63 +++++- netbench/src/cmd/run/mod.rs | 92 ++++++++- netbench/src/config/client.rs | 21 ++ netbench/src/config/mod.rs | 11 ++ netbench/src/config/product.rs | 269 ++++++++++++++++++++++++++ netbench/src/config/scenario.rs | 99 ++++++++++ netbench/src/config/server.rs | 25 +++ netbench/src/main.rs | 3 +- proxy/Cargo.toml | 4 +- proxy/src/firewall/domain_matcher.rs | 46 +++++ proxy/src/firewall/mod.rs | 3 + proxy/src/firewall/rule/chrome.rs | 14 +- proxy/src/firewall/rule/npm.rs | 10 +- proxy/src/firewall/rule/pypi.rs | 15 +- proxy/src/firewall/rule/vscode/mod.rs | 17 +- 17 files changed, 650 insertions(+), 207 deletions(-) create mode 100644 netbench/src/config/client.rs create mode 100644 netbench/src/config/mod.rs create mode 100644 netbench/src/config/product.rs create mode 100644 netbench/src/config/scenario.rs create mode 100644 netbench/src/config/server.rs create mode 100644 proxy/src/firewall/domain_matcher.rs diff --git a/Cargo.lock b/Cargo.lock index 34600692..f8ec6f6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -729,18 +729,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "faststr" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baec6a0289d7f1fe5665586ef7340af82e3037207bef60f5785e57569776f0c8" -dependencies = [ - "bytes", - "rkyv", - "serde", - "simdutf8", -] - [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -1589,26 +1577,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -[[package]] -name = "munge" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" -dependencies = [ - "munge_macro", -] - -[[package]] -name = "munge_macro" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "netbench" version = "0.1.0" @@ -1618,7 +1586,6 @@ dependencies = [ "rama", "rand", "serde", - "serde_json", "tikv-jemallocator", "tokio", ] @@ -2095,26 +2062,6 @@ version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" -[[package]] -name = "ptr_meta" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "quote" version = "1.0.43" @@ -2656,15 +2603,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "rancor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" -dependencies = [ - "ptr_meta", -] - [[package]] name = "rand" version = "0.9.2" @@ -2723,26 +2661,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "regex" version = "1.12.2" @@ -2772,12 +2690,6 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" -[[package]] -name = "rend" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" - [[package]] name = "resolv-conf" version = "0.7.6" @@ -2798,35 +2710,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rkyv" -version = "0.8.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "360b333c61ae24e5af3ae7c8660bd6b21ccd8200dbbc5d33c2454421e85b9c69" -dependencies = [ - "bytes", - "hashbrown 0.16.1", - "indexmap", - "munge", - "ptr_meta", - "rancor", - "rend", - "rkyv_derive", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.8.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02f8cdd12b307ab69fe0acf4cd2249c7460eb89dce64a0febadf934ebb6a9e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "rustc-hash" version = "2.1.1" @@ -2928,7 +2811,6 @@ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" name = "safechain-proxy" version = "0.1.0" dependencies = [ - "aho-corasick", "apple-native-keyring-store", "arc-swap", "clap", @@ -2948,7 +2830,6 @@ dependencies = [ "serde_html_form", "serde_json", "serde_test", - "sonic-rs", "static_vcruntime", "sudo", "tikv-jemallocator", @@ -3138,12 +3019,6 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "slab" version = "0.4.11" @@ -3189,45 +3064,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "sonic-number" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a74044c092f4f43ca7a6cfd62854cf9fb5ac8502b131347c990bf22bef1dfe" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "sonic-rs" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4425ea8d66ec950e0a8f2ef52c766cc3d68d661d9a0845c353c40833179fd866" -dependencies = [ - "ahash", - "bumpalo", - "bytes", - "cfg-if", - "faststr", - "itoa", - "ref-cast", - "ryu", - "serde", - "simdutf8", - "sonic-number", - "sonic-simd", - "thiserror", -] - -[[package]] -name = "sonic-simd" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5707edbfb34a40c9f2a55fa09a49101d9fec4e0cc171ce386086bd9616f34257" -dependencies = [ - "cfg-if", -] - [[package]] name = "spin" version = "0.9.8" diff --git a/netbench/Cargo.toml b/netbench/Cargo.toml index 67f22171..baeb6729 100644 --- a/netbench/Cargo.toml +++ b/netbench/Cargo.toml @@ -12,7 +12,6 @@ resolver = "3" clap = { version = "4.5", features = ["derive"] } tokio = { version = "1.48", features = ["full"] } serde = "1.0" -serde_json = "1" rand = "0.9" [target.'cfg(target_family = "unix")'.dependencies] diff --git a/netbench/src/cmd/mock/mod.rs b/netbench/src/cmd/mock/mod.rs index 3b939ebd..50b46811 100644 --- a/netbench/src/cmd/mock/mod.rs +++ b/netbench/src/cmd/mock/mod.rs @@ -1,10 +1,67 @@ use clap::Args; -use rama::error::OpaqueError; +use rama::{error::OpaqueError, telemetry::tracing}; + +use crate::config::{Scenario, ServerConfig}; #[derive(Debug, Clone, Args)] /// run bench mock server -pub struct MockCommand {} +pub struct MockCommand { + #[clap(flatten)] + config: Option, + + #[arg(long)] + /// Scenario to run, + /// manually defined parameters overwrite scenario parameters. + scenario: Option, +} + +pub async fn exec(args: MockCommand) -> Result<(), OpaqueError> { + let _merged_cfg = merge_server_cfg(args); -pub async fn exec(_args: MockCommand) -> Result<(), OpaqueError> { Ok(()) } + +fn merge_server_cfg(args: MockCommand) -> ServerConfig { + let scenario_cfg = args + .scenario + .map(|s| { + tracing::info!("use scenario to define base config: {s:?}"); + s.server_config() + }) + .unwrap_or_else(|| { + tracing::info!("no scenario defined, use default as base config"); + Default::default() + }); + + let overwrite_cfg = args.config.unwrap_or_default(); + + macro_rules! merge_config { + ($scenario:ident, $overwrite:ident, {$($property:ident),+ $(,)?}) => { + ServerConfig { + $( + $property: if let Some(value) = $overwrite.$property { + tracing::info!("property '{}': use overwrite: {value}", stringify!($property)); + Some(value) + } else if let Some(value) = $scenario.$property { + tracing::info!("property '{}': use scenario: {value}", stringify!($property)); + Some(value) + } else { + tracing::info!("property '{}': undefined", stringify!($property)); + None + }, + )+ + } + }; + } + + merge_config!( + scenario_cfg, overwrite_cfg, + { + base_latency, + jitter, + error_rate, + drop_rate, + timeout_rate, + } + ) +} diff --git a/netbench/src/cmd/run/mod.rs b/netbench/src/cmd/run/mod.rs index 4aab841e..944f6684 100644 --- a/netbench/src/cmd/run/mod.rs +++ b/netbench/src/cmd/run/mod.rs @@ -1,13 +1,93 @@ -use std::time::Duration; - use clap::Args; -use rama::error::OpaqueError; +use rama::{error::OpaqueError, telemetry::tracing}; + +use crate::config::{ClientConfig, ProductValues, Scenario, parse_product_values, rand_requests}; #[derive(Debug, Clone, Args)] /// run benhmarker -pub struct RunCommand {} +pub struct RunCommand { + #[clap(flatten)] + config: Option, + + /// Duration of the samples + #[arg(long, value_name = "SECONDS", default_value_t = 10.)] + duration: f64, + + /// Amount of times we run through the samples + #[arg(long, default_value_t = 4)] + iterations: usize, + + #[arg(long, value_parser = parse_product_values)] + /// Scenario to run, + /// manually defined parameters overwrite scenario parameters. + products: Option, + + #[arg(long)] + /// Scenario to run, + /// manually defined parameters overwrite scenario parameters. + scenario: Option, +} + +pub async fn exec(args: RunCommand) -> Result<(), OpaqueError> { + let merged_cfg = merge_server_cfg(args.scenario, args.config); + + let target_rps = merged_cfg.target_rps.unwrap_or(1000); + let total_request_count = (args.duration * target_rps as f64).next_up() as usize; + + let iterations = args.iterations.max(1); + let mut requests_per_iteration = Vec::with_capacity(iterations); + for i in 0..iterations { + tracing::info!( + "generate #{total_request_count} random requests for iteration {i} / {iterations}" + ); + let requests = rand_requests(total_request_count, args.products.clone()).await?; + requests_per_iteration.push(requests); + } + + println!("{requests_per_iteration:?}"); -pub async fn exec(_args: RunCommand) -> Result<(), OpaqueError> { - tokio::time::sleep(Duration::from_secs(5)).await; Ok(()) } + +fn merge_server_cfg(scenario: Option, config: Option) -> ClientConfig { + let scenario_cfg = scenario + .map(|s| { + tracing::info!("use scenario to define base config: {s:?}"); + s.client_config() + }) + .unwrap_or_else(|| { + tracing::info!("no scenario defined, use default as base config"); + Default::default() + }); + + let overwrite_cfg = config.unwrap_or_default(); + + macro_rules! merge_config { + ($scenario:ident, $overwrite:ident, {$($property:ident),+ $(,)?}) => { + ClientConfig { + $( + $property: if let Some(value) = $overwrite.$property { + tracing::info!("property '{}': use overwrite: {value}", stringify!($property)); + Some(value) + } else if let Some(value) = $scenario.$property { + tracing::info!("property '{}': use scenario: {value}", stringify!($property)); + Some(value) + } else { + tracing::info!("property '{}': undefined", stringify!($property)); + None + }, + )+ + } + }; + } + + merge_config!( + scenario_cfg, overwrite_cfg, + { + target_rps, + concurrency, + jitter, + burst_size, + } + ) +} diff --git a/netbench/src/config/client.rs b/netbench/src/config/client.rs new file mode 100644 index 00000000..88bafd91 --- /dev/null +++ b/netbench/src/config/client.rs @@ -0,0 +1,21 @@ +/// Client side load generation configuration. +/// This models how requests are produced over time. +#[derive(Debug, Clone, clap::Args, Default)] +pub struct ClientConfig { + /// Target average requests per second. + #[arg(long, value_name = "SECONDS")] + pub target_rps: Option, + + /// Maximum number of in flight requests. + #[arg(long, value_name = "N")] + pub concurrency: Option, + + /// Random scheduling delay added per request. + /// Models uneven producers and event loop jitter. + #[arg(long, value_name = "SECONDS")] + pub jitter: Option, + + /// Number of requests sent together before a pause. + #[arg(long, value_name = "#REQUESTS")] + pub burst_size: Option, +} diff --git a/netbench/src/config/mod.rs b/netbench/src/config/mod.rs new file mode 100644 index 00000000..564633dd --- /dev/null +++ b/netbench/src/config/mod.rs @@ -0,0 +1,11 @@ +mod client; +mod product; +mod scenario; +mod server; + +pub use self::{ + client::ClientConfig, + product::{Product, ProductValues, parse_product_values, rand_requests}, + scenario::Scenario, + server::ServerConfig, +}; diff --git a/netbench/src/config/product.rs b/netbench/src/config/product.rs new file mode 100644 index 00000000..5298252a --- /dev/null +++ b/netbench/src/config/product.rs @@ -0,0 +1,269 @@ +use std::{ + collections::{HashMap, hash_map::Entry}, + sync::LazyLock, + time::Duration, +}; + +use rama::{ + Layer as _, Service as _, + error::{ErrorContext as _, OpaqueError}, + http::{ + Body, BodyExtractExt, Request, Response, Uri, + client::EasyHttpWebClient, + headers::specifier::{Quality, QualityValue}, + layer::{ + decompression::DecompressionLayer, + map_request_body::MapRequestBodyLayer, + map_response_body::MapResponseBodyLayer, + retry::{ManagedPolicy, RetryLayer}, + timeout::TimeoutLayer, + }, + service::client::HttpClientExt as _, + }, + layer::MapErrLayer, + service::BoxService, + utils::{ + backoff::ExponentialBackoff, + collections::{NonEmptyVec, non_empty_vec}, + rng::HasherRng, + }, +}; + +use rand::{ + distr::{Distribution as _, weighted::WeightedIndex}, + rng, + seq::IndexedRandom, +}; +use serde::Deserialize; +use tokio::sync::Mutex; + +rama::utils::macros::enums::enum_builder! { + /// Some of the products we support and which to support + /// explicitly in the benchmarks + @String + pub enum Product { + /// No product + None => "none" | "-", + /// Visual Studio Code + VSCode => "vscode", + /// Python Package Index + PyPI => "pypi", + } +} + +/// Generate N random requests for the given product ratio +pub async fn rand_requests( + request_count: usize, + products: Option, +) -> Result, OpaqueError> { + let products = products.unwrap_or_else(default_product_values); + + let mut requests = Vec::with_capacity(request_count); + + let weights: Vec<_> = products.iter().map(|p| p.quality.as_u16()).collect(); + let dist = WeightedIndex::new(&weights).unwrap(); + for _ in 0..request_count { + let product = products[dist.sample(&mut rand::rng())].value.clone(); + let uri = generate_random_uri(product).await?; + + let mut req = Request::new(Body::empty()); + *req.uri_mut() = uri; + requests.push(req); + } + + Ok(requests) +} + +pub fn parse_product_values(input: &str) -> Result { + let result: Result>, _> = + input.split(",").map(|s| s.parse()).collect(); + match result { + Ok(values) => NonEmptyVec::try_from(values).map_err(|err| err.to_string()), + Err(err) => Err(err.to_string()), + } +} + +/// Ratio of product values to be used for generating tests +pub type ProductValues = NonEmptyVec>; + +/// Default [`ProductValues`] used in case none are defined in cli args. +fn default_product_values() -> ProductValues { + non_empty_vec![ + QualityValue::new(Product::None, Quality::one()), + QualityValue::new(Product::VSCode, Quality::new_clamped(100)), + QualityValue::new(Product::PyPI, Quality::new_clamped(100)), + ] +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ListDataEntry { + pub package_name: String, + pub version: Option, +} + +// TODO: move malware download to other module and cache it in tmp fs file + +async fn generate_random_uri(product: Product) -> Result { + static LISTS: LazyLock>>> = + LazyLock::new(Default::default); + let mut lists = LISTS.lock().await; + let list = lists.entry(product.clone()); + let entries = match list { + Entry::Occupied(ref occupied_entry) => occupied_entry.get(), + Entry::Vacant(vacant_entry) => { + let fresh_entries = match product { + Product::None | Product::Unknown(_) => vec![], + Product::VSCode => { + download_malware_list_for_uri( + "https://malware-list.aikido.dev/malware_vscode.json", + ) + .await? + } + Product::PyPI => { + download_malware_list_for_uri( + "https://malware-list.aikido.dev/malware_pypi.json", + ) + .await? + } + }; + vacant_entry.insert(fresh_entries) + } + }; + + match product { + Product::None | Product::Unknown(_) => Ok(Uri::from_static( + [ + "http://example.com", + "https://example.com", + "https://aikido.dev", + "https://malware-list.aikido.dev/malware_pypi.json", + "https://http-test.ramaproxy.org/method", + "https://http-test.ramaproxy.org/response-stream", + "https://http-test.ramaproxy.org/response-compression", + ] + .choose(&mut rng()) + .context("select random None uri")?, + )), + Product::VSCode => { + const DOMAINS: &[&str] = &[ + "gallery.vsassets.io", + "gallerycdn.vsassets.io", + "marketplace.visualstudio.com", + "netbench-foo.gallery.vsassets.io", + "netbench-foo.gallerycdn.vsassets.io", + ]; + const PATH_TEMPLATES: &[&str] = &[ + "/files////foo", + "/extensions///foo", + "/_apis/public/gallery/publishers//vsextensions//foo", + "/_apis/public/gallery/publisher///foo", + "/_apis/public/gallery/publisher//extension//foo", + ]; + + let domain = DOMAINS + .choose(&mut rng()) + .context("select random pypi domain")?; + let path_template = PATH_TEMPLATES + .choose(&mut rng()) + .context("select random pypi path template")?; + + if rand::random_bool(0.1) { + let entry = entries + .choose(&mut rng()) + .context("select random pypi malware")?; + let (publisher, extension) = entry + .package_name + .split_once(".") + .unwrap_or(("aikido", entry.package_name.as_str())); + let path = path_template + .replace("", publisher) + .replace("", extension) + .replace("", entry.version.as_deref().unwrap_or("any")); + format!("https://{domain}{path}") + .parse() + .context("parse pypi uri") + } else { + let path = path_template + .replace("", "aikido") + .replace("", "netbench-foo") + .replace("", "foo"); + format!("https://{domain}{path}") + .parse() + .context("parse pypi uri") + } + } + Product::PyPI => { + const URI_TEMPLATES: &[&str] = &[ + "https://pypi.org/pypi//json", + "https://pypi.org/simple//", + "https://files.pythonhosted.org/packages/abc/def/--py3-none-any.whl", + "https://files.pythonhosted.org/packages/source/d//-.tar.gz", + "https://pypi.org/pypi//json", + "https://pypi.org/", + "https://pypi.org/help/", + ]; + + let template = URI_TEMPLATES + .choose(&mut rng()) + .context("select random vscode uri template")?; + + if rand::random_bool(0.1) { + let entry = entries + .choose(&mut rng()) + .context("select random vscode malware")?; + template + .replace("", &entry.package_name) + .replace("", entry.version.as_deref().unwrap_or("any")) + .parse() + .context("parse vscode uri") + } else { + template + .replace("", "netbench-foo") + .replace("", "bar") + .parse() + .context("parse vscode uri") + } + } + } +} + +async fn download_malware_list_for_uri(uri: &str) -> Result, OpaqueError> { + shared_download_client() + .get(uri) + .send() + .await + .context("send malware list download req")? + .error_for_status() + .context("unexpected http status")? + .try_into_json() + .await + .context("deserialize malware list json payload") +} + +fn shared_download_client() -> BoxService { + static CLIENT: LazyLock> = LazyLock::new(|| { + let inner_https_client = EasyHttpWebClient::default(); + ( + MapResponseBodyLayer::new(Body::new), + DecompressionLayer::new(), + MapErrLayer::new(OpaqueError::from_std), + TimeoutLayer::new(Duration::from_secs(60)), // NOTE: if you have slow servers this might need to be more + RetryLayer::new( + ManagedPolicy::default().with_backoff( + ExponentialBackoff::new( + Duration::from_millis(100), + Duration::from_secs(30), + 0.01, + HasherRng::default, + ) + .expect("create exponential backoff impl"), + ), + ), + MapRequestBodyLayer::new(Body::new), + ) + .into_layer(inner_https_client) + .boxed() + }); + + CLIENT.clone() +} diff --git a/netbench/src/config/scenario.rs b/netbench/src/config/scenario.rs new file mode 100644 index 00000000..2b519dad --- /dev/null +++ b/netbench/src/config/scenario.rs @@ -0,0 +1,99 @@ +use super::{ClientConfig, ServerConfig}; + +/// High level benchmark scenarios. +/// Each scenario is a preset of client and server behavior. +#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)] +pub enum Scenario { + /// Ideal conditions. + /// Used to measure pure overhead and regressions. + #[default] + Baseline, + + /// Variable latency on both client and server. + /// Used to observe queuing and tail latency. + LatencyJitter, + + /// Unstable upstream behavior. + /// Used to test error handling and resilience. + FlakyUpstream, +} + +impl Scenario { + /// Construct the concrete client configuration + /// associated with this scenario. + pub fn client_config(self) -> ClientConfig { + match self { + Scenario::Baseline => { + // Smooth request generation with no randomness. + ClientConfig { + target_rps: Some(1000), + concurrency: Some(10), + jitter: None, + burst_size: Some(1), + } + } + + Scenario::LatencyJitter => { + // Requests are sent at an uneven pace. + // This introduces burstiness and queue formation. + ClientConfig { + target_rps: Some(1000), + concurrency: Some(20), + jitter: Some(0.005), + burst_size: Some(2), + } + } + + Scenario::FlakyUpstream => { + // Client side jitter is higher to simulate unstable producers. + ClientConfig { + target_rps: Some(600), + concurrency: Some(25), + jitter: Some(0.01), + burst_size: Some(2), + } + } + } + } + + /// Construct the concrete server configuration + /// associated with this scenario. + pub fn server_config(self) -> ServerConfig { + match self { + Scenario::Baseline => { + // Fast and fully reliable server. + ServerConfig { + base_latency: Some(0.02), + jitter: None, + error_rate: None, + drop_rate: None, + timeout_rate: None, + } + } + + Scenario::LatencyJitter => { + // Server processing time varies per request. + // This is the main source of tail latency. + ServerConfig { + base_latency: Some(0.05), + jitter: Some(1.), + error_rate: None, + drop_rate: None, + timeout_rate: None, + } + } + + Scenario::FlakyUpstream => { + // Server occasionally errors, drops, or stalls. + // This exercises retry paths and cleanup logic. + ServerConfig { + base_latency: Some(0.1), + jitter: Some(2.), + error_rate: Some(0.05), + drop_rate: Some(0.05), + timeout_rate: Some(0.05), + } + } + } + } +} diff --git a/netbench/src/config/server.rs b/netbench/src/config/server.rs new file mode 100644 index 00000000..3ff2cb45 --- /dev/null +++ b/netbench/src/config/server.rs @@ -0,0 +1,25 @@ +/// Server side behavior configuration. +/// This models processing cost and instability. +#[derive(Debug, Clone, clap::Args, Default)] +pub struct ServerConfig { + /// Base processing time before responding. + #[arg(long, value_name = "SECONDS")] + pub base_latency: Option, + + /// Random delay added to base_latency. + /// Models IO waits and backend variability. + #[arg(long, value_name = "SECONDS")] + pub jitter: Option, + + /// Probability of returning an error response. + #[arg(long)] + pub error_rate: Option, + + /// Probability of dropping the connection. + #[arg(long)] + pub drop_rate: Option, + + /// Probability of never responding within client timeout. + #[arg(long)] + pub timeout_rate: Option, +} diff --git a/netbench/src/main.rs b/netbench/src/main.rs index e06b55c8..59d0d003 100644 --- a/netbench/src/main.rs +++ b/netbench/src/main.rs @@ -9,6 +9,7 @@ use rama::{ use clap::{Parser, Subcommand}; pub mod cmd; +pub mod config; pub mod utils; #[cfg(target_family = "unix")] @@ -40,7 +41,7 @@ pub struct Args { #[arg(long, short = 'o', global = true)] pub output: Option, - #[arg(long, value_name = "SECONDS", default_value_t = 1., global = true)] + #[arg(long, value_name = "SECONDS", default_value_t = 0., global = true)] /// the graceful shutdown timeout (<= 0.0 = no timeout) pub graceful: f64, } diff --git a/proxy/Cargo.toml b/proxy/Cargo.toml index dd379ae7..665588ac 100644 --- a/proxy/Cargo.toml +++ b/proxy/Cargo.toml @@ -25,10 +25,8 @@ lz4_flex = "0.12" serde_test = "1" radix_trie = "0.3" serde_json = "1" -sonic-rs = "0.5" -aho-corasick = "1.1" rand = "0.9" -parking_lot = "0.12.5" +parking_lot = "0.12" serde_html_form = "0.4" [target.'cfg(target_os = "linux")'.dependencies] diff --git a/proxy/src/firewall/domain_matcher.rs b/proxy/src/firewall/domain_matcher.rs new file mode 100644 index 00000000..91239072 --- /dev/null +++ b/proxy/src/firewall/domain_matcher.rs @@ -0,0 +1,46 @@ +use rama::net::address::{AsDomainRef, Domain, DomainParentMatch, DomainTrie}; + +pub(super) struct DomainMatcher(DomainTrie); + +impl DomainMatcher { + pub(super) fn is_match(&self, domain: &Domain) -> bool { + match self.0.match_parent(domain) { + None => false, + Some(DomainParentMatch { + value: DomainAllowMode::Exact, + is_exact, + .. + }) => is_exact, + Some(DomainParentMatch { + value: DomainAllowMode::Parent, + .. + }) => true, + } + } + + pub(super) fn iter(&self) -> impl Iterator { + self.0.iter().map(|t| t.0) + } +} + +impl FromIterator for DomainMatcher { + fn from_iter>(iter: T) -> Self { + let mut domains = DomainTrie::new(); + for domain in iter { + if let Some(parent) = domain.as_wildcard_parent() + && let Ok(_) = parent.try_as_wildcard() + { + domains.insert_domain(parent, DomainAllowMode::Parent); + } else { + domains.insert_domain(domain, DomainAllowMode::Exact); + } + } + Self(domains) + } +} + +#[derive(Debug, Clone)] +enum DomainAllowMode { + Exact, + Parent, +} diff --git a/proxy/src/firewall/mod.rs b/proxy/src/firewall/mod.rs index 648ca3b8..54fd202b 100644 --- a/proxy/src/firewall/mod.rs +++ b/proxy/src/firewall/mod.rs @@ -29,8 +29,11 @@ pub mod malware_list; pub mod notifier; pub mod rule; +mod domain_matcher; mod pac; +use self::domain_matcher::DomainMatcher; + use crate::storage::SyncCompactDataStorage; use self::rule::{RequestAction, Rule}; diff --git a/proxy/src/firewall/rule/chrome.rs b/proxy/src/firewall/rule/chrome.rs index 62b00402..d752120f 100644 --- a/proxy/src/firewall/rule/chrome.rs +++ b/proxy/src/firewall/rule/chrome.rs @@ -3,7 +3,7 @@ use std::{borrow::Cow, fmt}; use rama::{ error::OpaqueError, http::{Request, Response, service::web::extract::Query}, - net::address::{Domain, DomainTrie}, + net::address::Domain, telemetry::tracing, utils::str::{ arcstr::{ArcStr, arcstr}, @@ -15,6 +15,7 @@ use serde::Deserialize; use crate::{ firewall::{ + DomainMatcher, events::{BlockedArtifact, BlockedEventInfo}, pac::PacScriptGenerator, }, @@ -25,7 +26,7 @@ use crate::{ use super::{BlockedRequest, RequestAction, Rule}; pub(in crate::firewall) struct RuleChrome { - target_domains: DomainTrie<()>, + target_domains: DomainMatcher, } impl RuleChrome { @@ -33,10 +34,7 @@ impl RuleChrome { _data: SyncCompactDataStorage, // NOTE data will be used to backup malware list once you use a remote list here ) -> Result { Ok(Self { - target_domains: ["clients2.google.com"] - .into_iter() - .map(|domain| (Domain::from_static(domain), ())) - .collect(), + target_domains: ["clients2.google.com"].into_iter().collect(), }) } } @@ -62,12 +60,12 @@ impl Rule for RuleChrome { #[inline(always)] fn match_domain(&self, domain: &Domain) -> bool { - self.target_domains.is_match_parent(domain) + self.target_domains.is_match(domain) } #[inline(always)] fn collect_pac_domains(&self, generator: &mut PacScriptGenerator) { - for (domain, _) in self.target_domains.iter() { + for domain in self.target_domains.iter() { generator.write_domain(&domain); } } diff --git a/proxy/src/firewall/rule/npm.rs b/proxy/src/firewall/rule/npm.rs index e52f7765..356fbe97 100644 --- a/proxy/src/firewall/rule/npm.rs +++ b/proxy/src/firewall/rule/npm.rs @@ -5,13 +5,14 @@ use rama::{ error::{ErrorContext as _, OpaqueError}, graceful::ShutdownGuard, http::{Request, Response, Uri}, - net::address::{Domain, DomainTrie}, + net::address::Domain, telemetry::tracing, utils::str::arcstr::{ArcStr, arcstr}, }; use crate::{ firewall::{ + DomainMatcher, events::{BlockedArtifact, BlockedEventInfo}, malware_list::{MalwareEntry, PackageVersion, RemoteMalwareList}, pac::PacScriptGenerator, @@ -23,7 +24,7 @@ use crate::{ use super::{BlockedRequest, RequestAction, Rule}; pub(in crate::firewall) struct RuleNpm { - target_domains: DomainTrie<()>, + target_domains: DomainMatcher, remote_malware_list: RemoteMalwareList, } @@ -57,7 +58,6 @@ impl RuleNpm { "registry.yarnpkg.com", ] .into_iter() - .map(|domain| (Domain::from_static(domain), ())) .collect(), remote_malware_list, }) @@ -78,12 +78,12 @@ impl Rule for RuleNpm { #[inline(always)] fn match_domain(&self, domain: &Domain) -> bool { - self.target_domains.is_match_parent(domain) + self.target_domains.is_match(domain) } #[inline(always)] fn collect_pac_domains(&self, generator: &mut PacScriptGenerator) { - for (domain, _) in self.target_domains.iter() { + for domain in self.target_domains.iter() { generator.write_domain(&domain); } } diff --git a/proxy/src/firewall/rule/pypi.rs b/proxy/src/firewall/rule/pypi.rs index 08ac7a40..65903249 100644 --- a/proxy/src/firewall/rule/pypi.rs +++ b/proxy/src/firewall/rule/pypi.rs @@ -5,10 +5,7 @@ use rama::{ error::{ErrorContext as _, OpaqueError}, graceful::ShutdownGuard, http::{Request, Response, Uri}, - net::{ - address::{Domain, DomainTrie}, - uri::util::percent_encoding, - }, + net::{address::Domain, uri::util::percent_encoding}, telemetry::tracing, utils::{ collections::smallvec::SmallVec, @@ -19,8 +16,9 @@ use rama::{ use rama::utils::str::arcstr::{ArcStr, arcstr}; use crate::{ - firewall::events::{BlockedArtifact, BlockedEventInfo}, firewall::{ + DomainMatcher, + events::{BlockedArtifact, BlockedEventInfo}, malware_list::{MalwareEntry, PackageVersion, RemoteMalwareList}, pac::PacScriptGenerator, }, @@ -46,7 +44,7 @@ impl PackageInfo { } pub(in crate::firewall) struct RulePyPI { - target_domains: DomainTrie<()>, + target_domains: DomainMatcher, remote_malware_list: RemoteMalwareList, } @@ -70,7 +68,6 @@ impl RulePyPI { let target_domains = ["pypi.org", "files.pythonhosted.org", "pypi.python.org"] .into_iter() - .map(|d| (Domain::from_static(d), ())) .collect(); Ok(Self { @@ -141,12 +138,12 @@ impl Rule for RulePyPI { #[inline(always)] fn match_domain(&self, domain: &Domain) -> bool { - self.target_domains.is_match_parent(domain) + self.target_domains.is_match(domain) } #[inline(always)] fn collect_pac_domains(&self, generator: &mut PacScriptGenerator) { - for (domain, _) in self.target_domains.iter() { + for domain in self.target_domains.iter() { generator.write_domain(&domain); } } diff --git a/proxy/src/firewall/rule/vscode/mod.rs b/proxy/src/firewall/rule/vscode/mod.rs index 1b180a4d..7cd2192f 100644 --- a/proxy/src/firewall/rule/vscode/mod.rs +++ b/proxy/src/firewall/rule/vscode/mod.rs @@ -5,7 +5,7 @@ use rama::{ error::{ErrorContext as _, OpaqueError}, graceful::ShutdownGuard, http::{Request, Response, Uri}, - net::address::{Domain, DomainTrie}, + net::address::Domain, telemetry::tracing, utils::str::smol_str::{SmolStr, format_smolstr}, }; @@ -13,8 +13,12 @@ use rama::{ use rama::utils::str::arcstr::{ArcStr, arcstr}; use crate::{ - firewall::events::{BlockedArtifact, BlockedEventInfo}, - firewall::{malware_list::RemoteMalwareList, pac::PacScriptGenerator}, + firewall::{ + DomainMatcher, + events::{BlockedArtifact, BlockedEventInfo}, + malware_list::RemoteMalwareList, + pac::PacScriptGenerator, + }, http::response::generate_malware_blocked_response_for_req, storage::SyncCompactDataStorage, }; @@ -22,7 +26,7 @@ use crate::{ use super::{BlockedRequest, RequestAction, Rule}; pub(in crate::firewall) struct RuleVSCode { - target_domains: DomainTrie<()>, + target_domains: DomainMatcher, remote_malware_list: RemoteMalwareList, } @@ -53,7 +57,6 @@ impl RuleVSCode { "*.gallerycdn.vsassets.io", ] .into_iter() - .map(|domain| (Domain::from_static(domain), ())) .collect(), remote_malware_list, }) @@ -74,12 +77,12 @@ impl Rule for RuleVSCode { #[inline(always)] fn match_domain(&self, domain: &Domain) -> bool { - self.target_domains.is_match_parent(domain) + self.target_domains.is_match(domain) } #[inline(always)] fn collect_pac_domains(&self, generator: &mut PacScriptGenerator) { - for (domain, _) in self.target_domains.iter() { + for domain in self.target_domains.iter() { generator.write_domain(&domain); } } From 466157f11c93795ab1a1d18a0be4db71bdf653b8 Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 22 Jan 2026 17:42:05 +0100 Subject: [PATCH 03/52] minor patch --- netbench/src/config/product.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbench/src/config/product.rs b/netbench/src/config/product.rs index 5298252a..2f2a77db 100644 --- a/netbench/src/config/product.rs +++ b/netbench/src/config/product.rs @@ -167,6 +167,8 @@ async fn generate_random_uri(product: Product) -> Result { .choose(&mut rng()) .context("select random pypi path template")?; + // TODO: make this configurable via cli arg + if rand::random_bool(0.1) { let entry = entries .choose(&mut rng()) @@ -247,7 +249,7 @@ fn shared_download_client() -> BoxService { MapResponseBodyLayer::new(Body::new), DecompressionLayer::new(), MapErrLayer::new(OpaqueError::from_std), - TimeoutLayer::new(Duration::from_secs(60)), // NOTE: if you have slow servers this might need to be more + TimeoutLayer::new(Duration::from_secs(60)), RetryLayer::new( ManagedPolicy::default().with_backoff( ExponentialBackoff::new( From 96f451a45595d105dcf7e30bbd37c21cb304a0f7 Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 22 Jan 2026 19:44:41 +0100 Subject: [PATCH 04/52] prepare code for proxy reusage and similar codebase refactor --- Cargo.lock | 19 +++- Cargo.toml | 33 +++++- proxy/Cargo.toml | 50 ++++----- proxy/src/{main.rs => cli.rs} | 81 +++++--------- proxy/src/lib.rs | 17 +++ proxy/src/server/meta/mod.rs | 104 ++++++++++++++---- proxy/src/server/proxy/mod.rs | 102 +++++++++++++---- proxy/src/test/e2e/runtime.rs | 4 +- proxy/src/tls/mod.rs | 7 +- proxy/src/utils/telemetry.rs | 24 ++-- proxy_cli/Cargo.toml | 28 +++++ {proxy => proxy_cli}/build.rs | 0 proxy_cli/src/main.rs | 35 ++++++ {netbench => proxy_netbench}/Cargo.toml | 18 +-- .../src/cmd/mock/mod.rs | 0 {netbench => proxy_netbench}/src/cmd/mod.rs | 0 .../src/cmd/run/mod.rs | 0 .../src/config/client.rs | 0 .../src/config/mod.rs | 0 .../src/config/product.rs | 0 .../src/config/scenario.rs | 0 .../src/config/server.rs | 0 {netbench => proxy_netbench}/src/main.rs | 0 {netbench => proxy_netbench}/src/utils/mod.rs | 0 .../src/utils/telemetry.rs | 0 25 files changed, 366 insertions(+), 156 deletions(-) rename proxy/src/{main.rs => cli.rs} (80%) create mode 100644 proxy/src/lib.rs create mode 100644 proxy_cli/Cargo.toml rename {proxy => proxy_cli}/build.rs (100%) create mode 100644 proxy_cli/src/main.rs rename {netbench => proxy_netbench}/Cargo.toml (53%) rename {netbench => proxy_netbench}/src/cmd/mock/mod.rs (100%) rename {netbench => proxy_netbench}/src/cmd/mod.rs (100%) rename {netbench => proxy_netbench}/src/cmd/run/mod.rs (100%) rename {netbench => proxy_netbench}/src/config/client.rs (100%) rename {netbench => proxy_netbench}/src/config/mod.rs (100%) rename {netbench => proxy_netbench}/src/config/product.rs (100%) rename {netbench => proxy_netbench}/src/config/scenario.rs (100%) rename {netbench => proxy_netbench}/src/config/server.rs (100%) rename {netbench => proxy_netbench}/src/main.rs (100%) rename {netbench => proxy_netbench}/src/utils/mod.rs (100%) rename {netbench => proxy_netbench}/src/utils/telemetry.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index f8ec6f6b..f2055584 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1579,7 +1579,7 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "netbench" -version = "0.1.0" +version = "0.2.1" dependencies = [ "clap", "mimalloc", @@ -2809,7 +2809,20 @@ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "safechain-proxy" -version = "0.1.0" +version = "0.2.1" +dependencies = [ + "clap", + "mimalloc", + "rama", + "safechain-proxy-lib", + "static_vcruntime", + "tikv-jemallocator", + "tokio", +] + +[[package]] +name = "safechain-proxy-lib" +version = "0.2.1" dependencies = [ "apple-native-keyring-store", "arc-swap", @@ -2818,7 +2831,6 @@ dependencies = [ "keyring-core", "linux-keyutils-keyring-store", "lz4_flex", - "mimalloc", "parking_lot", "postcard", "radix_trie", @@ -2832,7 +2844,6 @@ dependencies = [ "serde_test", "static_vcruntime", "sudo", - "tikv-jemallocator", "tokio", "tracing-test", "windows-native-keyring-store", diff --git a/Cargo.toml b/Cargo.toml index 718b33de..6fb0563b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,38 @@ [workspace] -members = ["netbench", "proxy"] +members = ["proxy", "proxy_cli", "proxy_netbench"] resolver = "3" +[workspace.package] +version = "0.2.1" +edition = "2024" +rust-version = "1.93" + +[workspace.dependencies] +apple-native-keyring-store = "0.2" +arc-swap = "1.7" +clap = "4.5" +humantime = "2.3" +jemallocator = { package = "tikv-jemallocator", version = "0.6" } +keyring-core = "0.7" +linux-keyutils-keyring-store = "0.2" +lz4_flex = "0.12" +mimalloc = { version = "0.1", default-features = false } +parking_lot = "0.12" +postcard = "1.1" +radix_trie = "0.3" +rand = "0.9" +safechain-proxy-lib = { path = "./proxy", version = "0.2.1" } +secrecy = "0.10" +semver = "1.0" +serde = "1.0" +serde_html_form = "0.4" +serde_json = "1" +serde_test = "1" +static_vcruntime = "3.0" +sudo = "0.6" +tokio = "1.48" +windows-native-keyring-store = "0.5" + [workspace.dependencies.rama] git = "https://github.com/plabayo/rama" rev = "03e88f1ccdf260a8c9a3be16735198855ee44f44" diff --git a/proxy/Cargo.toml b/proxy/Cargo.toml index 665588ac..1c7adcfb 100644 --- a/proxy/Cargo.toml +++ b/proxy/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "safechain-proxy" -description = "MITM SafeChain HTTP(S)/SOCKS5 Proxy for Developer Security" -version = "0.1.0" +name = "safechain-proxy-lib" +description = "Core library for safechain-proxy (cli)" +version = { workspace = true } edition = "2024" publish = false rust-version = "1.91" @@ -12,39 +12,35 @@ resolver = "3" har = [] [dependencies] -arc-swap = "1.7" +arc-swap = { workspace = true } clap = { version = "4.5", features = ["derive"] } -keyring-core = "0.7" -humantime = "2.3" -secrecy = "0.10" -semver = { version = "1.0", features = ["serde"] } -tokio = { version = "1.48", features = ["full"] } -serde = "1.0" -postcard = { version = "1.1", features = ["alloc"] } -lz4_flex = "0.12" -serde_test = "1" -radix_trie = "0.3" -serde_json = "1" -rand = "0.9" -parking_lot = "0.12" -serde_html_form = "0.4" +keyring-core = { workspace = true } +humantime = { workspace = true } +secrecy = { workspace = true } +semver = { workspace = true, features = ["serde"] } +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true } +postcard = { workspace = true, features = ["alloc"] } +lz4_flex = { workspace = true } +serde_test = { workspace = true } +radix_trie = { workspace = true } +serde_json = { workspace = true } +rand = { workspace = true } +parking_lot = { workspace = true } +serde_html_form = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] -linux-keyutils-keyring-store = "0.2" +linux-keyutils-keyring-store = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] -apple-native-keyring-store = { version = "0.2", features = ["keychain"] } -sudo = "0.6" - -[target.'cfg(target_family = "unix")'.dependencies] -jemallocator = { package = "tikv-jemallocator", version = "0.6" } +apple-native-keyring-store = { workspace = true, features = ["keychain"] } +sudo = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] -windows-native-keyring-store = "0.5" -mimalloc = { version = "0.1", default-features = false } +windows-native-keyring-store = { workspace = true } [target.'cfg(target_os = "windows")'.build-dependencies] -static_vcruntime = "3.0" +static_vcruntime = { workspace = true } [dependencies.rama] workspace = true diff --git a/proxy/src/main.rs b/proxy/src/cli.rs similarity index 80% rename from proxy/src/main.rs rename to proxy/src/cli.rs index 942aa6fa..7a82e88c 100644 --- a/proxy/src/main.rs +++ b/proxy/src/cli.rs @@ -1,3 +1,5 @@ +//! CLI utilities and types + use std::{path::PathBuf, time::Duration}; use rama::{ @@ -5,33 +7,14 @@ use rama::{ graceful::{self, ShutdownGuard}, http::Uri, net::{address::SocketAddress, socket::Interface}, + rt::Executor, telemetry::tracing::{self, Instrument as _}, tls::boring::server::TlsAcceptorLayer, }; use clap::Parser; -pub mod client; -pub mod diagnostics; -pub mod firewall; -pub mod http; -pub mod server; -pub mod storage; -pub mod tls; -pub mod utils; - -#[cfg(target_family = "unix")] -#[global_allocator] -static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc; - -#[cfg(target_os = "windows")] -#[global_allocator] -static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; - -#[cfg(test)] -pub mod test; - -/// CLI arguments for configuring proxy behavior. +// CLI arguments for configuring proxy behavior. #[derive(Debug, Clone, Parser)] #[command(name = "safechain-proxy")] #[command(bin_name = "safechain-proxy")] @@ -56,7 +39,7 @@ pub struct Args { value_name = "keyring | memory | ", default_value = "keyring" )] - pub secrets: self::storage::SyncSecrets, + pub secrets: crate::storage::SyncSecrets, /// debug logging as default instead of Info; use RUST_LOG env for more options #[arg(long, short = 'v', default_value_t = false)] @@ -98,34 +81,19 @@ pub struct Args { pub reporting_endpoint: Option, } -#[tokio::main] -async fn main() -> Result<(), BoxError> { - let args = Args::parse(); - - self::utils::telemetry::init_tracing(&args)?; - - let base_shutdown_signal = graceful::default_signal(); - if let Err(err) = run_with_args(base_shutdown_signal, args).await { - eprintln!("🚩 exit with error: {err}"); - std::process::exit(1); - } - - Ok(()) -} - /// Runs all the safechain-proxy services and blocks until /// a critical error occurs or the (graceful) shutdown has been initiated. /// /// This entry point is used by both the (binary) `main` function as well as /// for the e2e test suite found in the test module. -async fn run_with_args(base_shutdown_signal: F, args: Args) -> Result<(), BoxError> +pub async fn run_with_args(base_shutdown_signal: F, args: Args) -> Result<(), BoxError> where F: Future + Send + 'static, { tokio::fs::create_dir_all(&args.data) .await .with_context(|| format!("create data directory at path '{}'", args.data.display()))?; - let data_storage = self::storage::SyncCompactDataStorage::try_new(args.data.clone()) + let data_storage = crate::storage::SyncCompactDataStorage::try_new(args.data.clone()) .with_context(|| { format!( "create compact data storage using dir at path '{}'", @@ -136,21 +104,21 @@ where let graceful_timeout = (args.graceful > 0.).then(|| Duration::from_secs_f64(args.graceful)); - let (tls_acceptor, root_ca) = - self::tls::new_tls_acceptor_layer(&args, &data_storage).context("prepare TLS acceptor")?; + let (tls_acceptor, root_ca) = crate::tls::new_tls_acceptor_layer(&args.secrets, &data_storage) + .context("prepare TLS acceptor")?; let (error_tx, error_rx) = tokio::sync::mpsc::channel::(1); let graceful = graceful::Shutdown::new(new_shutdown_signal(error_rx, base_shutdown_signal)); #[cfg(feature = "har")] let (har_client, har_export_layer) = - { self::diagnostics::har::HarClient::new(&args.data, graceful.guard()) }; + { crate::diagnostics::har::HarClient::new(&args.data, graceful.guard()) }; // ensure to not wait for firewall creation in case shutdown was initiated, // this can happen for example in case remote lists need to be fetched and the // something on the network on either side is not working let firewall = tokio::select! { - result = self::firewall::Firewall::try_new( + result = crate::firewall::Firewall::try_new( graceful.guard(), data_storage, args.reporting_endpoint.clone(), @@ -223,15 +191,16 @@ async fn run_meta_https_server( guard: ShutdownGuard, error_tx: tokio::sync::mpsc::Sender, tls_acceptor: TlsAcceptorLayer, - root_ca: self::tls::RootCA, + root_ca: crate::tls::RootCA, proxy_addr_rx: tokio::sync::oneshot::Receiver, - firewall: self::firewall::Firewall, - #[cfg(feature = "har")] har_client: self::diagnostics::har::HarClient, + firewall: crate::firewall::Firewall, + #[cfg(feature = "har")] har_client: crate::diagnostics::har::HarClient, ) { tracing::info!("spawning meta http(s) server..."); - if let Err(err) = self::server::meta::run_meta_https_server( - args, - guard, + if let Err(err) = crate::server::meta::run_meta_https_server( + args.meta_bind, + &args.data, + Executor::graceful(guard), tls_acceptor, root_ca, proxy_addr_rx, @@ -241,7 +210,7 @@ async fn run_meta_https_server( ) .instrument(tracing::debug_span!( "meta server lifetime", - server.service.name = format!("{}-meta", self::utils::env::project_name()), + server.service.name = format!("{}-meta", crate::utils::env::project_name()), otel.kind = "server", network.protocol.name = "http", )) @@ -258,12 +227,14 @@ async fn run_proxy_server( error_tx: tokio::sync::mpsc::Sender, tls_acceptor: TlsAcceptorLayer, proxy_addr_tx: tokio::sync::oneshot::Sender, - firewall: self::firewall::Firewall, - #[cfg(feature = "har")] har_export_layer: self::diagnostics::har::HARExportLayer, + firewall: crate::firewall::Firewall, + #[cfg(feature = "har")] har_export_layer: crate::diagnostics::har::HARExportLayer, ) { tracing::info!("spawning proxy server..."); - if let Err(err) = self::server::proxy::run_proxy_server( - args, + if let Err(err) = crate::server::proxy::run_proxy_server( + args.bind, + &args.data, + args.mitm_all, guard, tls_acceptor, proxy_addr_tx, @@ -273,7 +244,7 @@ async fn run_proxy_server( ) .instrument(tracing::debug_span!( "proxy server lifetime", - server.service.name = self::utils::env::project_name(), + server.service.name = crate::utils::env::project_name(), otel.kind = "server", network.protocol.name = "tcp", )) diff --git a/proxy/src/lib.rs b/proxy/src/lib.rs new file mode 100644 index 00000000..a31067c7 --- /dev/null +++ b/proxy/src/lib.rs @@ -0,0 +1,17 @@ +//! Library for safechain proxy containing most of its core code. +//! +//! This allows the code to also be shared where desired +//! with developer tooling such as netbench. + +pub mod cli; +pub mod client; +pub mod diagnostics; +pub mod firewall; +pub mod http; +pub mod server; +pub mod storage; +pub mod tls; +pub mod utils; + +#[cfg(test)] +pub mod test; diff --git a/proxy/src/server/meta/mod.rs b/proxy/src/server/meta/mod.rs index 9f0dc912..6e9fd6b4 100644 --- a/proxy/src/server/meta/mod.rs +++ b/proxy/src/server/meta/mod.rs @@ -1,10 +1,9 @@ -use std::{sync::Arc, time::Duration}; +use std::{path::Path, sync::Arc, time::Duration}; use rama::{ - Layer, + Layer, Service, error::{ErrorContext, OpaqueError}, extensions::ExtensionsRef as _, - graceful::ShutdownGuard, http::{ HeaderValue, Request, StatusCode, layer::{required_header::AddRequiredResponseHeadersLayer, trace::TraceLayer}, @@ -17,10 +16,11 @@ use rama::{ layer::TimeoutLayer, net::{ address::SocketAddress, + socket::Interface, tls::{SecureTransport, server::TlsPeekRouter}, }, rt::Executor, - tcp::server::TcpListener, + tcp::{TcpStream, server::TcpListener}, telemetry::tracing, tls::boring::server::TlsAcceptorLayer, }; @@ -28,24 +28,47 @@ use rama::{ #[cfg(feature = "har")] use crate::diagnostics::har::HarClient; -use crate::{Args, firewall::Firewall, tls::RootCA}; +use crate::{firewall::Firewall, tls::RootCA}; + +#[derive(Debug)] +/// Meta HTTP(S) server that can serve meta connections. +/// +/// You can create it using [`build_meta_https_server`] +/// or build and run it directly using [`run_meta_https_server`]. +/// +/// The first is useful for lib usage, while the latter is mostly +/// for the proxycli use-case. +pub struct MetaServer { + service: S, + socket_address: SocketAddress, + listener: TcpListener, +} -pub async fn run_meta_https_server( - args: Args, - guard: ShutdownGuard, +impl MetaServer +where + S: Service + Clone, +{ + /// The (local) address this meta server is bound to. + pub fn socket_address(&self) -> SocketAddress { + self.socket_address + } + + /// Serve meta connections from this server. + pub async fn serve(self) -> Result<(), OpaqueError> { + self.listener.serve(self.service).await; + Ok(()) + } +} + +pub async fn build_meta_https_server( + bind: Interface, + exec: Executor, tls_acceptor: TlsAcceptorLayer, root_ca: RootCA, - proxy_addr_rx: tokio::sync::oneshot::Receiver, + proxy_addr: SocketAddress, firewall: Firewall, #[cfg(feature = "har")] har_client: HarClient, -) -> Result<(), OpaqueError> { - let proxy_addr = tokio::time::timeout(Duration::from_secs(8), proxy_addr_rx) - .await - .context("wait to recv proxy addr from proxy task")? - .context("recv proxy addr from proxy task")?; - - tracing::info!("meta HTTP(S) server received proxy address from proxy task: {proxy_addr}"); - +) -> Result + Clone>, OpaqueError> { #[cfg_attr(not(feature = "har"), allow(unused_mut))] let mut http_router = Router::new() .with_get("/", Html(META_SITE_INDEX_HTML)) @@ -87,14 +110,13 @@ pub async fn run_meta_https_server( ) .into_layer(http_router); - let exec = Executor::graceful(guard.clone()); let http_server = HttpServer::auto(exec.clone()).service(Arc::new(http_svc)); let tcp_svc = TimeoutLayer::new(Duration::from_secs(60)).into_layer( TlsPeekRouter::new(tls_acceptor.into_layer(http_server.clone())).with_fallback(http_server), ); - let tcp_listener = TcpListener::bind(args.meta_bind, exec) + let tcp_listener = TcpListener::bind(bind, exec) .await .map_err(OpaqueError::from_boxed) .context("bind proxy meta http(s) server")?; @@ -102,14 +124,48 @@ pub async fn run_meta_https_server( let meta_addr = tcp_listener .local_addr() .context("get bound address for proxy meta http(s) server")?; - tracing::info!("meta http(s) server bound to: {meta_addr}"); - crate::server::write_server_socket_address_as_file(&args.data, "meta", meta_addr.into()) - .await?; - tcp_listener.serve(tcp_svc).await; + Ok(MetaServer { + service: tcp_svc, + socket_address: meta_addr.into(), + listener: tcp_listener, + }) +} + +#[allow(clippy::too_many_arguments)] +pub async fn run_meta_https_server( + bind: Interface, + data: &Path, + exec: Executor, + tls_acceptor: TlsAcceptorLayer, + root_ca: RootCA, + proxy_addr_rx: tokio::sync::oneshot::Receiver, + firewall: Firewall, + #[cfg(feature = "har")] har_client: HarClient, +) -> Result<(), OpaqueError> { + let proxy_addr = tokio::time::timeout(Duration::from_secs(8), proxy_addr_rx) + .await + .context("wait to recv proxy addr from proxy task")? + .context("recv proxy addr from proxy task")?; + tracing::info!("meta HTTP(S) server received proxy address from proxy task: {proxy_addr}"); + + let server = build_meta_https_server( + bind, + exec, + tls_acceptor, + root_ca, + proxy_addr, + firewall, + #[cfg(feature = "har")] + har_client, + ) + .await?; + + crate::server::write_server_socket_address_as_file(data, "meta", server.socket_address()) + .await?; - Ok(()) + server.serve().await } const META_SITE_INDEX_HTML: &str = r##" diff --git a/proxy/src/server/proxy/mod.rs b/proxy/src/server/proxy/mod.rs index 659db20c..e4da7a5f 100644 --- a/proxy/src/server/proxy/mod.rs +++ b/proxy/src/server/proxy/mod.rs @@ -1,7 +1,7 @@ -use std::sync::Arc; +use std::{path::Path, sync::Arc}; use rama::{ - Layer, + Layer, Service, error::{ErrorContext as _, OpaqueError}, extensions::ExtensionsMut, graceful::ShutdownGuard, @@ -19,13 +19,13 @@ use rama::{ }, layer::ConsumeErrLayer, net::{ - address::SocketAddress, http::RequestContext, proxy::ProxyTarget, + address::SocketAddress, http::RequestContext, proxy::ProxyTarget, socket::Interface, stream::layer::http::BodyLimitLayer, }, proxy::socks5::{self, Socks5Acceptor, server::Socks5PeekRouter}, rt::Executor, service::service_fn, - tcp::server::TcpListener, + tcp::{TcpStream, server::TcpListener}, telemetry::tracing::{self, Level}, tls::boring::server::TlsAcceptorLayer, }; @@ -36,7 +36,7 @@ use rama::{ utils::str::arcstr::arcstr, }; -use crate::{Args, firewall::Firewall}; +use crate::firewall::Firewall; #[cfg(feature = "har")] use crate::diagnostics::har::HARExportLayer; @@ -52,21 +52,52 @@ pub use self::auth::{FirewallUserConfig, HEADER_NAME_X_AIKIDO_SAFE_CHAIN_CONFIG} /// Protects against memory exhaustion from excessively large payloads. const MAX_BODY_SIZE: usize = 500 * 1024 * 1024; // 500 MB -/// Runs the MITM HTTP(S)/SOCKS(5) Proxy server, +#[derive(Debug)] +/// The MITM HTTP(S)/SOCKS(5) Proxy server, /// including the firewall for blocking relevant requests /// and modifying responses. -pub async fn run_proxy_server( - args: Args, +/// +/// You can create it using [`build_proxy_server`] +/// or build and run it directly using [`run_proxy_server`]. +/// +/// The first is useful for lib usage, while the latter is mostly +/// for the proxycli use-case. +pub struct ProxyServer { + service: S, + socket_address: SocketAddress, + listener: TcpListener, +} + +impl ProxyServer +where + S: Service + Clone, +{ + /// The (local) address this proxy server is bound to. + pub fn socket_address(&self) -> SocketAddress { + self.socket_address + } + + /// proxy connections from this (proxy) server. + pub async fn serve(self) -> Result<(), OpaqueError> { + self.listener + .serve(BodyLimitLayer::symmetric(MAX_BODY_SIZE).into_layer(self.service)) + .await; + Ok(()) + } +} + +pub async fn build_proxy_server( + bind: Interface, + mitm_all: bool, guard: ShutdownGuard, tls_acceptor: TlsAcceptorLayer, - proxy_addr_tx: tokio::sync::oneshot::Sender, firewall: Firewall, #[cfg(feature = "har")] har_export_layer: HARExportLayer, -) -> Result<(), OpaqueError> { +) -> Result + Clone>, OpaqueError> { let exec = Executor::graceful(guard.clone()); let tcp_service = TcpListener::build(exec.clone()) - .bind(args.bind) + .bind(bind) .await .map_err(OpaqueError::from_boxed) .context("bind TCP network interface for proxy")?; @@ -79,7 +110,7 @@ pub async fn run_proxy_server( let http_proxy_mitm_server = self::server::new_mitm_server( guard.clone(), - args.mitm_all, + mitm_all, tls_acceptor.clone(), firewall.clone(), #[cfg(feature = "har")] @@ -87,7 +118,7 @@ pub async fn run_proxy_server( )?; let socks5_proxy_mitm_server = self::server::new_mitm_server( guard.clone(), - args.mitm_all, + mitm_all, tls_acceptor, firewall, #[cfg(feature = "har")] @@ -136,21 +167,46 @@ pub async fn run_proxy_server( let tcp_inner_svc = socks5_proxy_router.with_fallback(http_service); tracing::info!(proxy.address = %proxy_addr, "local HTTP(S)/SOCKS5 proxy ready"); - crate::server::write_server_socket_address_as_file(&args.data, "proxy", proxy_addr.into()) - .await?; - if proxy_addr_tx.send(proxy_addr.into()).is_err() { + + Ok(ProxyServer { + service: tcp_inner_svc, + socket_address: proxy_addr.into(), + listener: tcp_service, + }) +} + +#[allow(clippy::too_many_arguments)] +pub async fn run_proxy_server( + bind: Interface, + data: &Path, + mitm_all: bool, + guard: ShutdownGuard, + tls_acceptor: TlsAcceptorLayer, + proxy_addr_tx: tokio::sync::oneshot::Sender, + firewall: Firewall, + #[cfg(feature = "har")] har_export_layer: HARExportLayer, +) -> Result<(), OpaqueError> { + let proxy_server = build_proxy_server( + bind, + mitm_all, + guard, + tls_acceptor, + firewall, + #[cfg(feature = "har")] + har_export_layer, + ) + .await?; + + let proxy_addr = proxy_server.socket_address(); + + crate::server::write_server_socket_address_as_file(data, "proxy", proxy_addr).await?; + if proxy_addr_tx.send(proxy_addr).is_err() { return Err(OpaqueError::from_display( "failed to send proxy addr to meta server task", )); } - // sent proxy addr to firewall - - tcp_service - .serve(BodyLimitLayer::symmetric(MAX_BODY_SIZE).into_layer(tcp_inner_svc)) - .await; - - Ok(()) + proxy_server.serve().await } async fn http_connect_accept(mut req: Request) -> Result<(Response, Request), Response> { diff --git a/proxy/src/test/e2e/runtime.rs b/proxy/src/test/e2e/runtime.rs index a7e0f507..421bcb1a 100644 --- a/proxy/src/test/e2e/runtime.rs +++ b/proxy/src/test/e2e/runtime.rs @@ -37,7 +37,7 @@ use rama::{ }; use crate::{ - Args, + cli::Args, server::proxy::{FirewallUserConfig, HEADER_NAME_X_AIKIDO_SAFE_CHAIN_CONFIG}, }; @@ -404,7 +404,7 @@ fn spawn_safechain_proxy_app_with_args(extra_args: &[&str]) -> PathBuf { .build() .unwrap(); - let server_future = crate::run_with_args(std::future::pending::<()>(), args); + let server_future = crate::cli::run_with_args(std::future::pending::<()>(), args); notify_server_ready.set(()).expect("waiter to be nofified"); diff --git a/proxy/src/tls/mod.rs b/proxy/src/tls/mod.rs index 1072ad7f..c440ede0 100644 --- a/proxy/src/tls/mod.rs +++ b/proxy/src/tls/mod.rs @@ -15,7 +15,7 @@ use rama::{ use secrecy::{ExposeSecret, SecretBox}; -use crate::{Args, storage::SyncCompactDataStorage}; +use crate::storage::{SyncCompactDataStorage, SyncSecrets}; mod root; @@ -35,11 +35,10 @@ impl RootCA { } pub fn new_tls_acceptor_layer( - args: &Args, + secrets: &SyncSecrets, data_storage: &SyncCompactDataStorage, ) -> Result<(TlsAcceptorLayer, RootCA), OpaqueError> { - let PemKeyCrtPair { crt, key } = - self::root::new_root_tls_crt_key_pair(&args.secrets, data_storage)?; + let PemKeyCrtPair { crt, key } = self::root::new_root_tls_crt_key_pair(secrets, data_storage)?; let root_ca = RootCA(Arc::new(SecretBox::new(Box::new(crt.as_ref().to_owned())))); diff --git a/proxy/src/utils/telemetry.rs b/proxy/src/utils/telemetry.rs index d5815b32..dca9c02b 100644 --- a/proxy/src/utils/telemetry.rs +++ b/proxy/src/utils/telemetry.rs @@ -1,4 +1,4 @@ -use std::io::IsTerminal as _; +use std::{io::IsTerminal as _, path::Path}; use rama::{ error::{BoxError, ErrorContext as _}, @@ -9,21 +9,31 @@ use rama::{ }, }; -use crate::Args; +#[derive(Debug, Default)] +pub struct TelemetryConfig<'a> { + /// Log verbose (for more control use `RUST_LOG` env var) + pub verbose: bool, + /// Enable pretty logging (human-friendly, not for computer integestion) + pub pretty: bool, + /// Log to a file instead of stderr. + pub output: Option<&'a Path>, +} /// Configures structured logging with runtime control via `RUST_LOG` environment variable. /// /// Defaults to INFO level to balance visibility with performance. /// Use `RUST_LOG=debug` or `RUST_LOG=trace` for troubleshooting. -pub fn init_tracing(args: &Args) -> Result<(), BoxError> { - let directive = if args.verbose { +pub fn init_tracing(cfg: Option>) -> Result<(), BoxError> { + let cfg = cfg.unwrap_or_default(); + + let directive = if cfg.verbose { LevelFilter::DEBUG } else { LevelFilter::INFO } .into(); - let make_writer = match args.output.as_deref() { + let make_writer = match cfg.output { Some(path) => { let file = std::fs::OpenOptions::new() .append(true) @@ -37,7 +47,7 @@ pub fn init_tracing(args: &Args) -> Result<(), BoxError> { }; let subscriber = tracing::subscriber::fmt() - .with_ansi(args.output.is_none() && std::io::stderr().is_terminal()) + .with_ansi(cfg.output.is_none() && std::io::stderr().is_terminal()) .with_env_filter( EnvFilter::builder() .with_default_directive(directive) @@ -45,7 +55,7 @@ pub fn init_tracing(args: &Args) -> Result<(), BoxError> { ) .with_writer(make_writer); - if args.pretty { + if cfg.pretty { subscriber.pretty().try_init()?; } else { subscriber.try_init()?; diff --git a/proxy_cli/Cargo.toml b/proxy_cli/Cargo.toml new file mode 100644 index 00000000..25343eb3 --- /dev/null +++ b/proxy_cli/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "safechain-proxy" +description = "MITM SafeChain HTTP(S)/SOCKS5 Proxy for Developer Security" +version = { workspace = true } +edition = "2024" +publish = false +rust-version = "1.91" +readme = "../docs/proxy.md" +resolver = "3" + +[features] +har = [] + +[dependencies] +rama = { workspace = true } +tokio = { workspace = true, features = ["full"] } +safechain-proxy-lib = { workspace = true } +clap = { workspace = true, features = ["derive"] } + + +[target.'cfg(target_family = "unix")'.dependencies] +jemallocator = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +mimalloc = { workspace = true } + +[target.'cfg(target_os = "windows")'.build-dependencies] +static_vcruntime = { workspace = true } diff --git a/proxy/build.rs b/proxy_cli/build.rs similarity index 100% rename from proxy/build.rs rename to proxy_cli/build.rs diff --git a/proxy_cli/src/main.rs b/proxy_cli/src/main.rs new file mode 100644 index 00000000..a726013a --- /dev/null +++ b/proxy_cli/src/main.rs @@ -0,0 +1,35 @@ +use rama::{error::BoxError, graceful}; + +use safechain_proxy_lib::{ + cli::{Args, run_with_args}, + utils::{self, telemetry::TelemetryConfig}, +}; + +use clap::Parser; + +#[cfg(target_family = "unix")] +#[global_allocator] +static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc; + +#[cfg(target_os = "windows")] +#[global_allocator] +static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + let args = Args::parse(); + + self::utils::telemetry::init_tracing(Some(TelemetryConfig { + verbose: args.verbose, + pretty: args.pretty, + output: args.output.as_deref(), + }))?; + + let base_shutdown_signal = graceful::default_signal(); + if let Err(err) = run_with_args(base_shutdown_signal, args).await { + eprintln!("🚩 exit with error: {err}"); + std::process::exit(1); + } + + Ok(()) +} diff --git a/netbench/Cargo.toml b/proxy_netbench/Cargo.toml similarity index 53% rename from netbench/Cargo.toml rename to proxy_netbench/Cargo.toml index baeb6729..7e249737 100644 --- a/netbench/Cargo.toml +++ b/proxy_netbench/Cargo.toml @@ -1,24 +1,24 @@ [package] name = "netbench" description = "Network Benchmark tool in function of safechain-proxy" -version = "0.1.0" -edition = "2024" +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } publish = false -rust-version = "1.91" readme = "../docs/netbench.md" resolver = "3" [dependencies] -clap = { version = "4.5", features = ["derive"] } -tokio = { version = "1.48", features = ["full"] } -serde = "1.0" -rand = "0.9" +clap = { workspace = true, features = ["derive"] } +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true } +rand = { workspace = true } [target.'cfg(target_family = "unix")'.dependencies] -jemallocator = { package = "tikv-jemallocator", version = "0.6" } +jemallocator = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] -mimalloc = { version = "0.1", default-features = false } +mimalloc = { workspace = true } [dependencies.rama] workspace = true diff --git a/netbench/src/cmd/mock/mod.rs b/proxy_netbench/src/cmd/mock/mod.rs similarity index 100% rename from netbench/src/cmd/mock/mod.rs rename to proxy_netbench/src/cmd/mock/mod.rs diff --git a/netbench/src/cmd/mod.rs b/proxy_netbench/src/cmd/mod.rs similarity index 100% rename from netbench/src/cmd/mod.rs rename to proxy_netbench/src/cmd/mod.rs diff --git a/netbench/src/cmd/run/mod.rs b/proxy_netbench/src/cmd/run/mod.rs similarity index 100% rename from netbench/src/cmd/run/mod.rs rename to proxy_netbench/src/cmd/run/mod.rs diff --git a/netbench/src/config/client.rs b/proxy_netbench/src/config/client.rs similarity index 100% rename from netbench/src/config/client.rs rename to proxy_netbench/src/config/client.rs diff --git a/netbench/src/config/mod.rs b/proxy_netbench/src/config/mod.rs similarity index 100% rename from netbench/src/config/mod.rs rename to proxy_netbench/src/config/mod.rs diff --git a/netbench/src/config/product.rs b/proxy_netbench/src/config/product.rs similarity index 100% rename from netbench/src/config/product.rs rename to proxy_netbench/src/config/product.rs diff --git a/netbench/src/config/scenario.rs b/proxy_netbench/src/config/scenario.rs similarity index 100% rename from netbench/src/config/scenario.rs rename to proxy_netbench/src/config/scenario.rs diff --git a/netbench/src/config/server.rs b/proxy_netbench/src/config/server.rs similarity index 100% rename from netbench/src/config/server.rs rename to proxy_netbench/src/config/server.rs diff --git a/netbench/src/main.rs b/proxy_netbench/src/main.rs similarity index 100% rename from netbench/src/main.rs rename to proxy_netbench/src/main.rs diff --git a/netbench/src/utils/mod.rs b/proxy_netbench/src/utils/mod.rs similarity index 100% rename from netbench/src/utils/mod.rs rename to proxy_netbench/src/utils/mod.rs diff --git a/netbench/src/utils/telemetry.rs b/proxy_netbench/src/utils/telemetry.rs similarity index 100% rename from netbench/src/utils/telemetry.rs rename to proxy_netbench/src/utils/telemetry.rs From d163dc9482fb1d8742631cfe64e0182283133e7c Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 22 Jan 2026 19:46:29 +0100 Subject: [PATCH 05/52] migrate from main --- proxy/Cargo.toml | 4 ++-- proxy_cli/Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/proxy/Cargo.toml b/proxy/Cargo.toml index 3f64fc8d..fed0eedb 100644 --- a/proxy/Cargo.toml +++ b/proxy/Cargo.toml @@ -2,9 +2,9 @@ name = "safechain-proxy-lib" description = "Core library for safechain-proxy (cli)" version = { workspace = true } -edition = "2024" +edition = { workspace = true } +rust-version = { workspace = true } publish = false -rust-version = "1.93" readme = "../docs/proxy.md" resolver = "3" diff --git a/proxy_cli/Cargo.toml b/proxy_cli/Cargo.toml index 25343eb3..57c1394a 100644 --- a/proxy_cli/Cargo.toml +++ b/proxy_cli/Cargo.toml @@ -2,9 +2,9 @@ name = "safechain-proxy" description = "MITM SafeChain HTTP(S)/SOCKS5 Proxy for Developer Security" version = { workspace = true } -edition = "2024" +edition = { workspace = true } +rust-version = { workspace = true } publish = false -rust-version = "1.91" readme = "../docs/proxy.md" resolver = "3" From 12139f53d5114f697eacc88d630eadc23b7e6980 Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 22 Jan 2026 20:48:47 +0100 Subject: [PATCH 06/52] integrate proxy in netbench tool --- Cargo.lock | 1 + proxy/src/cli.rs | 4 +- proxy/src/server/mod.rs | 2 +- proxy_netbench/Cargo.toml | 1 + proxy_netbench/src/cmd/mock/mod.rs | 11 ++- proxy_netbench/src/cmd/mod.rs | 1 + proxy_netbench/src/cmd/proxy/mod.rs | 101 ++++++++++++++++++++++++++ proxy_netbench/src/main.rs | 39 ++++++++-- proxy_netbench/src/utils/mod.rs | 1 - proxy_netbench/src/utils/telemetry.rs | 56 -------------- 10 files changed, 150 insertions(+), 67 deletions(-) create mode 100644 proxy_netbench/src/cmd/proxy/mod.rs delete mode 100644 proxy_netbench/src/utils/mod.rs delete mode 100644 proxy_netbench/src/utils/telemetry.rs diff --git a/Cargo.lock b/Cargo.lock index f2055584..7367b0df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1585,6 +1585,7 @@ dependencies = [ "mimalloc", "rama", "rand", + "safechain-proxy-lib", "serde", "tikv-jemallocator", "tokio", diff --git a/proxy/src/cli.rs b/proxy/src/cli.rs index 7a82e88c..64fd5619 100644 --- a/proxy/src/cli.rs +++ b/proxy/src/cli.rs @@ -75,9 +75,7 @@ pub struct Args { pub graceful: f64, /// Optional endpoint URL to POST blocked-event notifications to. - /// - /// If omitted, blocked events are still recorded locally but not reported. - #[arg(long = "reporting-endpoint", value_name = "URL")] + #[arg(long, value_name = "URL")] pub reporting_endpoint: Option, } diff --git a/proxy/src/server/mod.rs b/proxy/src/server/mod.rs index 8b230fea..4facf2a8 100644 --- a/proxy/src/server/mod.rs +++ b/proxy/src/server/mod.rs @@ -17,7 +17,7 @@ pub mod proxy; // but instead operate from within inside the proxy. pub mod connectivity; -async fn write_server_socket_address_as_file( +pub async fn write_server_socket_address_as_file( dir: &Path, name: &str, addr: SocketAddress, diff --git a/proxy_netbench/Cargo.toml b/proxy_netbench/Cargo.toml index 7e249737..913e2dc7 100644 --- a/proxy_netbench/Cargo.toml +++ b/proxy_netbench/Cargo.toml @@ -13,6 +13,7 @@ clap = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["full"] } serde = { workspace = true } rand = { workspace = true } +safechain-proxy-lib = { workspace = true, features = ["har"] } [target.'cfg(target_family = "unix")'.dependencies] jemallocator = { workspace = true } diff --git a/proxy_netbench/src/cmd/mock/mod.rs b/proxy_netbench/src/cmd/mock/mod.rs index 50b46811..1be3ea9a 100644 --- a/proxy_netbench/src/cmd/mock/mod.rs +++ b/proxy_netbench/src/cmd/mock/mod.rs @@ -1,5 +1,5 @@ use clap::Args; -use rama::{error::OpaqueError, telemetry::tracing}; +use rama::{error::OpaqueError, net::socket::Interface, telemetry::tracing}; use crate::config::{Scenario, ServerConfig}; @@ -13,6 +13,15 @@ pub struct MockCommand { /// Scenario to run, /// manually defined parameters overwrite scenario parameters. scenario: Option, + + /// network interface to bind to + #[arg( + long, + short = 'b', + value_name = "INTERFACE", + default_value = "127.0.0.1:0" + )] + pub bind: Interface, } pub async fn exec(args: MockCommand) -> Result<(), OpaqueError> { diff --git a/proxy_netbench/src/cmd/mod.rs b/proxy_netbench/src/cmd/mod.rs index d9276831..124b7590 100644 --- a/proxy_netbench/src/cmd/mod.rs +++ b/proxy_netbench/src/cmd/mod.rs @@ -1,2 +1,3 @@ pub mod mock; +pub mod proxy; pub mod run; diff --git a/proxy_netbench/src/cmd/proxy/mod.rs b/proxy_netbench/src/cmd/proxy/mod.rs new file mode 100644 index 00000000..f7c1c094 --- /dev/null +++ b/proxy_netbench/src/cmd/proxy/mod.rs @@ -0,0 +1,101 @@ +use std::path::PathBuf; + +use rama::{ + error::{ErrorContext as _, OpaqueError}, + graceful::ShutdownGuard, + http::Uri, + net::socket::Interface, + telemetry::tracing, +}; + +use clap::Args; +use safechain_proxy_lib::{diagnostics, firewall, server, storage, tls}; + +#[derive(Debug, Clone, Args)] +/// run proxy in function of benchmarker +pub struct ProxyCommand { + /// network interface to bind to + #[arg( + long, + short = 'b', + value_name = "INTERFACE", + default_value = "127.0.0.1:0" + )] + pub bind: Interface, + + #[arg(long)] + /// Record the entire proxy traffic to a HAR file. + pub record_har: bool, + + /// Optional endpoint URL to POST blocked-event notifications to. + #[arg(long, value_name = "URL")] + pub reporting_endpoint: Option, +} + +pub async fn exec( + data: PathBuf, + guard: ShutdownGuard, + secrets: storage::SyncSecrets, + args: ProxyCommand, +) -> Result<(), OpaqueError> { + tokio::fs::create_dir_all(&data) + .await + .with_context(|| format!("create data directory at path '{}'", data.display()))?; + let data_storage = + storage::SyncCompactDataStorage::try_new(data.clone()).with_context(|| { + format!( + "create compact data storage using dir at path '{}'", + data.display() + ) + })?; + tracing::info!(path = ?data, "data directory ready to be used"); + + let (tls_acceptor, _root_ca) = + tls::new_tls_acceptor_layer(&secrets, &data_storage).context("prepare TLS acceptor")?; + + // ensure to not wait for firewall creation in case shutdown was initiated, + // this can happen for example in case remote lists need to be fetched and the + // something on the network on either side is not working + let firewall = tokio::select! { + result = firewall::Firewall::try_new( + guard.clone(), + data_storage, + args.reporting_endpoint.clone(), + ) => { + result? + } + + _ = guard.cancelled() => { + return Err(OpaqueError::from_display( + "shutdown initiated prior to firewall created; exit process immediately", + )); + } + }; + + let (har_client, har_layer) = diagnostics::har::HarClient::new(&data, guard.clone()); + if args.record_har + && har_client + .toggle() + .await + .context("failed to enable HAR recording")? + { + return Err(OpaqueError::from_display( + "HAR recording was unexpectely already enabled", + )); + } + + let proxy_server = server::proxy::build_proxy_server( + args.bind, + false, + guard, + tls_acceptor, + firewall, + har_layer, + ) + .await?; + + let proxy_addr = proxy_server.socket_address(); + server::write_server_socket_address_as_file(&data, "proxy", proxy_addr).await?; + + proxy_server.serve().await +} diff --git a/proxy_netbench/src/main.rs b/proxy_netbench/src/main.rs index 59d0d003..4983bbc2 100644 --- a/proxy_netbench/src/main.rs +++ b/proxy_netbench/src/main.rs @@ -7,10 +7,10 @@ use rama::{ }; use clap::{Parser, Subcommand}; +use safechain_proxy_lib::{storage, utils}; pub mod cmd; pub mod config; -pub mod utils; #[cfg(target_family = "unix")] #[global_allocator] @@ -41,6 +41,27 @@ pub struct Args { #[arg(long, short = 'o', global = true)] pub output: Option, + /// directory in which data will be stored on the filesystem + #[arg( + long, + default_value = { + #[cfg(not(target_os = "windows"))] + { ".aikido/safechain-netbench" } + #[cfg(target_os = "windows")] + { ".aikido\\safechain-netbench" } + }, + global = true, + )] + pub data: PathBuf, + + /// secrets storage to use (e.g. for root CA) + #[arg( + long, + value_name = "keyring | memory | ", + default_value = "keyring" + )] + pub secrets: storage::SyncSecrets, + #[arg(long, value_name = "SECONDS", default_value_t = 0., global = true)] /// the graceful shutdown timeout (<= 0.0 = no timeout) pub graceful: f64, @@ -51,13 +72,18 @@ pub struct Args { enum CliCommands { Run(self::cmd::run::RunCommand), Mock(self::cmd::mock::MockCommand), + Proxy(self::cmd::proxy::ProxyCommand), } #[tokio::main] async fn main() -> Result<(), BoxError> { let args = Args::parse(); - self::utils::telemetry::init_tracing(&args)?; + utils::telemetry::init_tracing(Some(utils::telemetry::TelemetryConfig { + verbose: args.verbose, + pretty: args.pretty, + output: args.output.as_deref(), + }))?; let base_shutdown_signal = graceful::default_signal(); if let Err(err) = run_with_args(base_shutdown_signal, args).await { @@ -78,10 +104,13 @@ where let (error_tx, error_rx) = tokio::sync::oneshot::channel::(); let graceful = graceful::Shutdown::new(new_shutdown_signal(error_rx, base_shutdown_signal)); - graceful.spawn_task(async move { + graceful.spawn_task_fn(async move |guard| { let result = match args.cmds { - CliCommands::Run(args) => self::cmd::run::exec(args).await, - CliCommands::Mock(args) => self::cmd::mock::exec(args).await, + CliCommands::Run(run_args) => self::cmd::run::exec(run_args).await, + CliCommands::Mock(mock_args) => self::cmd::mock::exec(mock_args).await, + CliCommands::Proxy(proxy_args) => { + self::cmd::proxy::exec(args.data, guard, args.secrets, proxy_args).await + } }; if let Err(err) = result { let _ = error_tx.send(err); diff --git a/proxy_netbench/src/utils/mod.rs b/proxy_netbench/src/utils/mod.rs deleted file mode 100644 index 304af1e0..00000000 --- a/proxy_netbench/src/utils/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod telemetry; diff --git a/proxy_netbench/src/utils/telemetry.rs b/proxy_netbench/src/utils/telemetry.rs deleted file mode 100644 index d209ea2d..00000000 --- a/proxy_netbench/src/utils/telemetry.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::io::IsTerminal as _; - -use rama::{ - error::{BoxError, ErrorContext as _}, - telemetry::tracing::{ - self, - metadata::LevelFilter, - subscriber::{EnvFilter, fmt::writer::BoxMakeWriter}, - }, -}; - -use crate::Args; - -/// Configures structured logging with runtime control via `RUST_LOG` environment variable. -/// -/// Defaults to INFO level to balance visibility with performance. -/// Use `RUST_LOG=debug` or `RUST_LOG=trace` for troubleshooting. -pub fn init_tracing(args: &Args) -> Result<(), BoxError> { - let directive = if args.verbose { - LevelFilter::DEBUG - } else { - LevelFilter::INFO - } - .into(); - - let make_writer = match args.output.as_deref() { - Some(path) => { - let file = std::fs::OpenOptions::new() - .append(true) - .create(true) - .open(path) - .context("open log file")?; - - BoxMakeWriter::new(file) - } - None => BoxMakeWriter::new(std::io::stderr), - }; - - let subscriber = tracing::subscriber::fmt() - .with_ansi(args.output.is_none() && std::io::stderr().is_terminal()) - .with_env_filter( - EnvFilter::builder() - .with_default_directive(directive) - .from_env_lossy(), - ) - .with_writer(make_writer); - - if args.pretty { - subscriber.pretty().try_init()?; - } else { - subscriber.try_init()?; - } - - tracing::info!("Tracing is set up"); - Ok(()) -} From 59daa3fbc4c770759e9df583c653cf0003ab7627 Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 22 Jan 2026 21:27:53 +0100 Subject: [PATCH 07/52] reuse malware list download from safechain-proxy-lib --- proxy/src/firewall/malware_list.rs | 112 +++++++++++++++++++++++---- proxy_netbench/src/cmd/run/mod.rs | 50 ++++++++++-- proxy_netbench/src/config/product.rs | 51 ++++++------ proxy_netbench/src/main.rs | 2 +- 4 files changed, 163 insertions(+), 52 deletions(-) diff --git a/proxy/src/firewall/malware_list.rs b/proxy/src/firewall/malware_list.rs index f2739459..c29c1cbe 100644 --- a/proxy/src/firewall/malware_list.rs +++ b/proxy/src/firewall/malware_list.rs @@ -50,13 +50,14 @@ impl RemoteMalwareList { client, }; - let (malware_trie, e_tag) = match client.load_cached_malware_trie().await { - Ok(Some(cached_info)) => { + let (malware_trie, e_tag) = match client.load_cached_malware_list().await { + Ok(Some((malware_list, e_tag))) => { tracing::debug!( - "create new remote malware list (uri: {}) with cached trie", + "create new remote malware list (uri: {}) with cached list", client.uri ); - cached_info + let malware_trie = trie_from_malware_list(malware_list); + (malware_trie, e_tag) } Ok(None) => { tracing::debug!( @@ -97,6 +98,64 @@ impl RemoteMalwareList { trie: shared_malware_trie, }) } + + /// Useful in case you just want to download the list for meta/bench use-cases + pub async fn download_data_entry_list( + uri: Uri, + sync_storage: SyncCompactDataStorage, + client: C, + ) -> Result, OpaqueError> + where + C: Service, + { + let filename = url_to_filename(&uri); + let refresh_interval = Duration::MAX; // not used for this function + let client = RemoteMalwareListClient { + uri, + filename, + refresh_interval, + sync_storage, + client, + }; + + let malware_list = match client.load_cached_malware_list().await { + Ok(Some((malware_list, _))) => { + tracing::debug!( + "create new remote malware list (uri: {}) with cached list", + client.uri + ); + malware_list + } + Ok(None) => { + tracing::debug!( + "no cached malware list found for remote endpoint (uri: {}); download fresh list", + client.uri + ); + + let (malware_list, _) = client + .download_malware_list(None) + .await + .context("download new malware list")? + .context("new malware list not available")?; + malware_list + } + Err(err) => { + tracing::warn!( + "failed to load cached malware list for remote endpoint (uri: {}); download fresh list; err = {err}", + client.uri + ); + + let (malware_list, _) = client + .download_malware_list(None) + .await + .context("download new malware list")? + .context("new malware list not available")?; + malware_list + } + }; + + Ok(malware_list) + } } struct RemoteMalwareListClient { @@ -111,10 +170,10 @@ impl RemoteMalwareListClient where C: Service, { - async fn download_malware_trie( + async fn download_malware_list( &self, e_tag: Option<&str>, - ) -> Result)>, OpaqueError> { + ) -> Result, Option)>, OpaqueError> { let Some((malware_list, new_e_tag)) = self.fetch_remote_malware_list_and_e_tag(e_tag).await? else { @@ -123,13 +182,22 @@ where self.spawn_malware_list_caching_task(malware_list.clone(), new_e_tag.clone()); - let trie = trie_from_malware_list(malware_list); - tracing::debug!( - "malware trie refreshed with link to remote endpoint '{}'", + "malware list refreshed with link to remote endpoint '{}'", self.uri, ); + Ok(Some((malware_list, new_e_tag))) + } + + async fn download_malware_trie( + &self, + e_tag: Option<&str>, + ) -> Result)>, OpaqueError> { + let Some((malware_list, new_e_tag)) = self.download_malware_list(e_tag).await? else { + return Ok(None); + }; + let trie = trie_from_malware_list(malware_list); Ok(Some((trie, new_e_tag))) } @@ -214,13 +282,13 @@ where }); } - async fn load_cached_malware_trie( + async fn load_cached_malware_list( &self, - ) -> Result)>, OpaqueError> { + ) -> Result, Option)>, OpaqueError> { tokio::task::spawn_blocking({ let storage = self.sync_storage.clone(); let filename = self.filename.clone(); - move || load_cached_malware_trie_sync_inner(storage, filename) + move || load_cached_malware_list_sync_inner(storage, filename) }) .await .with_context(|| { @@ -238,10 +306,11 @@ where } } -fn load_cached_malware_trie_sync_inner( +#[allow(clippy::type_complexity)] +fn load_cached_malware_list_sync_inner( storage: SyncCompactDataStorage, filename: ArcStr, -) -> Result)>, OpaqueError> { +) -> Result, Option)>, OpaqueError> { let cached_malware_trie: Option = storage.load(&filename).context("storage failure")?; @@ -249,9 +318,7 @@ fn load_cached_malware_trie_sync_inner( return Ok(None); }; - let trie = trie_from_malware_list(cached_malware_trie.list); - - Ok(Some((trie, cached_malware_trie.e_tag))) + Ok(Some((cached_malware_trie.list, cached_malware_trie.e_tag))) } async fn remote_list_update_loop( @@ -461,6 +528,17 @@ impl PartialEq for semver::Version { } } +impl fmt::Display for PackageVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PackageVersion::Semver(version) => version.fmt(f), + PackageVersion::Any => "*".fmt(f), + PackageVersion::None => Ok(()), + PackageVersion::Unknown(arc_str) => arc_str.fmt(f), + } + } +} + impl Serialize for PackageVersion { fn serialize(&self, serializer: S) -> Result where diff --git a/proxy_netbench/src/cmd/run/mod.rs b/proxy_netbench/src/cmd/run/mod.rs index 944f6684..27a93ec2 100644 --- a/proxy_netbench/src/cmd/run/mod.rs +++ b/proxy_netbench/src/cmd/run/mod.rs @@ -1,5 +1,13 @@ +use std::path::PathBuf; + +use rama::{ + error::{ErrorContext as _, OpaqueError}, + telemetry::tracing, +}; + use clap::Args; -use rama::{error::OpaqueError, telemetry::tracing}; + +use safechain_proxy_lib::storage; use crate::config::{ClientConfig, ProductValues, Scenario, parse_product_values, rand_requests}; @@ -9,10 +17,14 @@ pub struct RunCommand { #[clap(flatten)] config: Option, - /// Duration of the samples + /// Iteration duration #[arg(long, value_name = "SECONDS", default_value_t = 10.)] duration: f64, + /// Warmup duration + #[arg(long, value_name = "SECONDS", default_value_t = 5.)] + warmup: f64, + /// Amount of times we run through the samples #[arg(long, default_value_t = 4)] iterations: usize, @@ -28,23 +40,47 @@ pub struct RunCommand { scenario: Option, } -pub async fn exec(args: RunCommand) -> Result<(), OpaqueError> { +pub async fn exec(data: PathBuf, args: RunCommand) -> Result<(), OpaqueError> { + tokio::fs::create_dir_all(&data) + .await + .with_context(|| format!("create data directory at path '{}'", data.display()))?; + let data_storage = + storage::SyncCompactDataStorage::try_new(data.clone()).with_context(|| { + format!( + "create compact data storage using dir at path '{}'", + data.display() + ) + })?; + tracing::info!(path = ?data, "data directory ready to be used"); + let merged_cfg = merge_server_cfg(args.scenario, args.config); let target_rps = merged_cfg.target_rps.unwrap_or(1000); - let total_request_count = (args.duration * target_rps as f64).next_up() as usize; + let request_count_per_iteration = (args.duration * target_rps as f64).next_up() as usize; + let request_count_per_warmup = (args.warmup * target_rps as f64).next_up() as usize; let iterations = args.iterations.max(1); let mut requests_per_iteration = Vec::with_capacity(iterations); for i in 0..iterations { tracing::info!( - "generate #{total_request_count} random requests for iteration {i} / {iterations}" + "generate #{request_count_per_iteration} random requests for iteration {i} / {iterations}" ); - let requests = rand_requests(total_request_count, args.products.clone()).await?; + let requests = rand_requests( + &data_storage, + request_count_per_iteration, + args.products.clone(), + ) + .await?; requests_per_iteration.push(requests); } - println!("{requests_per_iteration:?}"); + tracing::info!("generate #{request_count_per_warmup} random requests for warmup"); + let _requests = rand_requests( + &data_storage, + request_count_per_warmup, + args.products.clone(), + ) + .await?; Ok(()) } diff --git a/proxy_netbench/src/config/product.rs b/proxy_netbench/src/config/product.rs index 2f2a77db..3a2a8321 100644 --- a/proxy_netbench/src/config/product.rs +++ b/proxy_netbench/src/config/product.rs @@ -8,7 +8,7 @@ use rama::{ Layer as _, Service as _, error::{ErrorContext as _, OpaqueError}, http::{ - Body, BodyExtractExt, Request, Response, Uri, + Body, Request, Response, Uri, client::EasyHttpWebClient, headers::specifier::{Quality, QualityValue}, layer::{ @@ -18,7 +18,6 @@ use rama::{ retry::{ManagedPolicy, RetryLayer}, timeout::TimeoutLayer, }, - service::client::HttpClientExt as _, }, layer::MapErrLayer, service::BoxService, @@ -34,7 +33,7 @@ use rand::{ rng, seq::IndexedRandom, }; -use serde::Deserialize; +use safechain_proxy_lib::{firewall::malware_list, storage}; use tokio::sync::Mutex; rama::utils::macros::enums::enum_builder! { @@ -53,6 +52,7 @@ rama::utils::macros::enums::enum_builder! { /// Generate N random requests for the given product ratio pub async fn rand_requests( + sync_storage: &storage::SyncCompactDataStorage, request_count: usize, products: Option, ) -> Result, OpaqueError> { @@ -64,7 +64,7 @@ pub async fn rand_requests( let dist = WeightedIndex::new(&weights).unwrap(); for _ in 0..request_count { let product = products[dist.sample(&mut rand::rng())].value.clone(); - let uri = generate_random_uri(product).await?; + let uri = generate_random_uri(sync_storage, product).await?; let mut req = Request::new(Body::empty()); *req.uri_mut() = uri; @@ -95,16 +95,11 @@ fn default_product_values() -> ProductValues { ] } -#[derive(Debug, Clone, Deserialize)] -pub struct ListDataEntry { - pub package_name: String, - pub version: Option, -} - -// TODO: move malware download to other module and cache it in tmp fs file - -async fn generate_random_uri(product: Product) -> Result { - static LISTS: LazyLock>>> = +async fn generate_random_uri( + sync_storage: &storage::SyncCompactDataStorage, + product: Product, +) -> Result { + static LISTS: LazyLock>>> = LazyLock::new(Default::default); let mut lists = LISTS.lock().await; let list = lists.entry(product.clone()); @@ -115,12 +110,14 @@ async fn generate_random_uri(product: Product) -> Result { Product::None | Product::Unknown(_) => vec![], Product::VSCode => { download_malware_list_for_uri( + sync_storage.clone(), "https://malware-list.aikido.dev/malware_vscode.json", ) .await? } Product::PyPI => { download_malware_list_for_uri( + sync_storage.clone(), "https://malware-list.aikido.dev/malware_pypi.json", ) .await? @@ -180,7 +177,7 @@ async fn generate_random_uri(product: Product) -> Result { let path = path_template .replace("", publisher) .replace("", extension) - .replace("", entry.version.as_deref().unwrap_or("any")); + .replace("", &entry.version.to_string()); format!("https://{domain}{path}") .parse() .context("parse pypi uri") @@ -215,7 +212,7 @@ async fn generate_random_uri(product: Product) -> Result { .context("select random vscode malware")?; template .replace("", &entry.package_name) - .replace("", entry.version.as_deref().unwrap_or("any")) + .replace("", &entry.version.to_string()) .parse() .context("parse vscode uri") } else { @@ -229,17 +226,17 @@ async fn generate_random_uri(product: Product) -> Result { } } -async fn download_malware_list_for_uri(uri: &str) -> Result, OpaqueError> { - shared_download_client() - .get(uri) - .send() - .await - .context("send malware list download req")? - .error_for_status() - .context("unexpected http status")? - .try_into_json() - .await - .context("deserialize malware list json payload") +async fn download_malware_list_for_uri( + sync_storage: storage::SyncCompactDataStorage, + uri: &'static str, +) -> Result, OpaqueError> { + let client = shared_download_client(); + malware_list::RemoteMalwareList::download_data_entry_list( + Uri::from_static(uri), + sync_storage, + client, + ) + .await } fn shared_download_client() -> BoxService { diff --git a/proxy_netbench/src/main.rs b/proxy_netbench/src/main.rs index 4983bbc2..63aabbe4 100644 --- a/proxy_netbench/src/main.rs +++ b/proxy_netbench/src/main.rs @@ -106,7 +106,7 @@ where graceful.spawn_task_fn(async move |guard| { let result = match args.cmds { - CliCommands::Run(run_args) => self::cmd::run::exec(run_args).await, + CliCommands::Run(run_args) => self::cmd::run::exec(args.data, run_args).await, CliCommands::Mock(mock_args) => self::cmd::mock::exec(mock_args).await, CliCommands::Proxy(proxy_args) => { self::cmd::proxy::exec(args.data, guard, args.secrets, proxy_args).await From 153cf10538a870cc30ffa6d39dbfe4cb08b07f34 Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 23 Jan 2026 11:05:24 +0100 Subject: [PATCH 08/52] support egress overwrite (bench only) improve egress connection pool parameters --- proxy/Cargo.toml | 1 + proxy/src/client/mock_client/mod.rs | 7 ++- proxy/src/client/mod.rs | 56 ++++++++++++++++--- proxy/src/firewall/mod.rs | 8 +-- proxy/src/firewall/notifier.rs | 18 ++---- proxy/src/server/mod.rs | 9 +++ proxy/src/server/proxy/client.rs | 4 +- proxy/src/server/proxy/mod.rs | 2 +- proxy/src/server/proxy/server.rs | 9 ++- proxy/src/storage/secrets.rs | 9 +++ .../e2e/test_proxy/report_blocked_events.rs | 3 +- proxy/src/tls/mod.rs | 5 ++ proxy/src/utils/env.rs | 12 ++++ proxy_netbench/Cargo.toml | 2 +- proxy_netbench/src/cmd/proxy/mod.rs | 19 +++++-- proxy_netbench/src/main.rs | 12 +--- 16 files changed, 124 insertions(+), 52 deletions(-) diff --git a/proxy/Cargo.toml b/proxy/Cargo.toml index fed0eedb..6929e199 100644 --- a/proxy/Cargo.toml +++ b/proxy/Cargo.toml @@ -10,6 +10,7 @@ resolver = "3" [features] har = [] +bench = [] [dependencies] arc-swap = { workspace = true } diff --git a/proxy/src/client/mock_client/mod.rs b/proxy/src/client/mock_client/mod.rs index e0f79e63..df37261a 100644 --- a/proxy/src/client/mock_client/mod.rs +++ b/proxy/src/client/mock_client/mod.rs @@ -23,10 +23,11 @@ mod vscode_marketplace; static ASSERT_ENDPOINT_STATE: LazyLock = LazyLock::new(assert_endpoint::MockState::new); -pub fn new_mock_client() --> Result + Clone, OpaqueError> { +pub fn new_mock_client( + exec: Executor, +) -> Result + Clone, OpaqueError> { let echo_svc_builder = EchoServiceBuilder::default(); - let echo_svc = Arc::new(echo_svc_builder.build_http(Executor::default())); + let echo_svc = Arc::new(echo_svc_builder.build_http(exec)); let not_found_svc = service_fn(move |req| { let echo_svc = echo_svc.clone(); async move { echo_svc.serve(req).await.map(IntoResponse::into_response) } diff --git a/proxy/src/client/mod.rs b/proxy/src/client/mod.rs index 2e7505b1..18cb3a06 100644 --- a/proxy/src/client/mod.rs +++ b/proxy/src/client/mod.rs @@ -9,12 +9,23 @@ //! instead of rama/thirdparty clients as to ensure //! e2e test suites do not make actual external network requests. +#[cfg(all(not(test), feature = "bench"))] +use ::{ + parking_lot::Mutex, + rama::{combinators::Either, net::address::SocketAddress}, + std::sync::LazyLock, +}; #[cfg(not(test))] -use rama::{ - Service, - error::{ErrorContext as _, OpaqueError}, - http::{Request, Response, Version, client::EasyHttpWebClient}, - rt::Executor, +use ::{ + rama::{ + Service, + error::{ErrorContext as _, OpaqueError}, + http::{Request, Response, Version, client::EasyHttpWebClient}, + net::client::pool::http::HttpPooledConnectorConfig, + rt::Executor, + tcp::client::service::TcpConnector, + }, + std::time::Duration, }; #[cfg(test)] @@ -23,19 +34,46 @@ mod mock_client; #[cfg(test)] pub use self::mock_client::new_mock_client as new_web_client; +#[cfg(all(not(test), feature = "bench"))] +static EGRESS_ADDRESS_OVERWRITE: LazyLock>> = + LazyLock::new(Default::default); + +#[cfg(all(not(test), feature = "bench"))] +pub fn set_egress_address_overwrite(address: SocketAddress) { + let mut overwrite = EGRESS_ADDRESS_OVERWRITE.lock(); + *overwrite = Some(address); +} + /// Create a new web client that can be cloned and shared. #[cfg(not(test))] -pub fn new_web_client() --> Result + Clone, OpaqueError> { +pub fn new_web_client( + exec: Executor, +) -> Result + Clone, OpaqueError> { + let max_active = crate::utils::env::compute_concurrent_request_count(); + let max_total = max_active * 2; + + let tcp_connector = TcpConnector::new(exec); + + #[cfg(all(not(test), feature = "bench"))] + let tcp_connector = match *EGRESS_ADDRESS_OVERWRITE.lock() { + Some(value) => tcp_connector.with_connector(Either::A(value)), + None => tcp_connector.with_connector(Either::B(())), + }; + Ok(EasyHttpWebClient::connector_builder() - .with_default_transport_connector() + .with_custom_transport_connector(tcp_connector) .without_tls_proxy_support() .without_proxy_support() // fallback to HTTP/1.1 as default HTTP version in case // no protocol negotation happens on layers such as TLS (e.g. ALPN) .with_tls_support_using_boringssl_and_default_http_version(None, Version::HTTP_11) .with_default_http_connector(Executor::default()) - .try_with_default_connection_pool() + .try_with_connection_pool(HttpPooledConnectorConfig { + max_total, + max_active, + wait_for_pool_timeout: Some(Duration::from_secs(120)), + idle_timeout: Some(Duration::from_secs(300)), + }) .context("create connection pool for proxy web client")? .build_client()) } diff --git a/proxy/src/firewall/mod.rs b/proxy/src/firewall/mod.rs index 54fd202b..0320bdf9 100644 --- a/proxy/src/firewall/mod.rs +++ b/proxy/src/firewall/mod.rs @@ -53,7 +53,8 @@ impl Firewall { data: SyncCompactDataStorage, reporting_endpoint: Option, ) -> Result { - let inner_https_client = crate::client::new_web_client()?; + let exec = Executor::graceful(guard.clone()); + let inner_https_client = crate::client::new_web_client(exec.clone())?; let shared_remote_malware_client = ( MapResponseBodyLayer::new(Body::new), @@ -77,10 +78,7 @@ impl Firewall { .boxed(); let notifier = match reporting_endpoint { - Some(endpoint) => match self::notifier::EventNotifier::try_new( - Executor::graceful(guard.clone()), - endpoint, - ) { + Some(endpoint) => match self::notifier::EventNotifier::try_new(exec, endpoint) { Ok(notifier) => Some(notifier), Err(err) => { tracing::warn!( diff --git a/proxy/src/firewall/notifier.rs b/proxy/src/firewall/notifier.rs index f650a828..cc14848e 100644 --- a/proxy/src/firewall/notifier.rs +++ b/proxy/src/firewall/notifier.rs @@ -1,5 +1,7 @@ use std::{sync::Arc, time::Duration}; +use crate::utils::env; + use super::events::BlockedEvent; use rama::{ Service, @@ -27,8 +29,8 @@ impl std::fmt::Debug for EventNotifier { impl EventNotifier { pub fn try_new(exec: Executor, reporting_endpoint: Uri) -> Result { - let client = crate::client::new_web_client()?.boxed(); - let limit = Arc::new(Semaphore::const_new(compute_concurrent_request_count())); + let client = crate::client::new_web_client(exec.clone())?.boxed(); + let limit = Arc::new(Semaphore::const_new(env::compute_concurrent_request_count())); Ok(Self { exec, client, @@ -55,18 +57,6 @@ impl EventNotifier { } } -fn compute_concurrent_request_count() -> usize { - std::env::var("MAX_CONCURRENT_REQUESTS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or_else(|| { - let cpus = std::thread::available_parallelism() - .map(|n| n.get()) - .unwrap_or(1); - cpus * 64 - }) -} - async fn acquire_concurrency_guard<'a>( limits: &'a Semaphore, ) -> Result, OpaqueError> { diff --git a/proxy/src/server/mod.rs b/proxy/src/server/mod.rs index 4facf2a8..45c6c9b1 100644 --- a/proxy/src/server/mod.rs +++ b/proxy/src/server/mod.rs @@ -5,6 +5,8 @@ use rama::{ net::address::SocketAddress, }; +use crate::tls::RootCA; + // Real servers // // These have their own (ingress) socket(s). @@ -32,3 +34,10 @@ pub async fn write_server_socket_address_as_file( ) }) } + +pub async fn write_root_ca_as_file(dir: &Path, root_ca: &RootCA) -> Result<(), OpaqueError> { + let path = dir.join("root.ca.pem"); + tokio::fs::write(&path, root_ca.as_str()) + .await + .with_context(|| format!("write root CA to file '{}'", path.display())) +} diff --git a/proxy/src/server/proxy/client.rs b/proxy/src/server/proxy/client.rs index d2354417..5f518391 100644 --- a/proxy/src/server/proxy/client.rs +++ b/proxy/src/server/proxy/client.rs @@ -19,6 +19,7 @@ use rama::{ tls::{SecureTransport, client::ClientConfig}, user::UserId, }, + rt::Executor, telemetry::tracing::{self, Instrument as _}, tls::boring::client::TlsConnectorDataBuilder, }; @@ -31,6 +32,7 @@ pub(super) struct HttpClient { } pub(super) fn new_https_client( + exec: Executor, firewall: Firewall, ) -> Result>, OpaqueError> { @@ -46,7 +48,7 @@ pub(super) fn new_https_client( crate::server::connectivity::new_connectivity_http_svc(), ), ) - .into_layer(crate::client::new_web_client()?); + .into_layer(crate::client::new_web_client(exec)?); Ok(HttpClient { inner }) } diff --git a/proxy/src/server/proxy/mod.rs b/proxy/src/server/proxy/mod.rs index e4da7a5f..3f9581b3 100644 --- a/proxy/src/server/proxy/mod.rs +++ b/proxy/src/server/proxy/mod.rs @@ -106,7 +106,7 @@ pub async fn build_proxy_server( .local_addr() .context("fetch local addr of bound TCP port for proxy")?; - let https_client = self::client::new_https_client(firewall.clone())?; + let https_client = self::client::new_https_client(exec.clone(), firewall.clone())?; let http_proxy_mitm_server = self::server::new_mitm_server( guard.clone(), diff --git a/proxy/src/server/proxy/server.rs b/proxy/src/server/proxy/server.rs index 70019ccf..b8a27ee4 100644 --- a/proxy/src/server/proxy/server.rs +++ b/proxy/src/server/proxy/server.rs @@ -60,6 +60,8 @@ pub(super) fn new_mitm_server( firewall: Firewall, #[cfg(feature = "har")] har_export_layer: HARExportLayer, ) -> Result + Clone>, OpaqueError> { + let exec = Executor::graceful(guard); + let https_svc = ( TraceLayer::new_for_http(), ConsumeErrLayer::trace(Level::DEBUG).with_response(StaticHttpProxyError), @@ -71,9 +73,10 @@ pub(super) fn new_mitm_server( MapResponseBodyLayer::new(Body::new), CompressionLayer::new(), ) - .into_layer(super::client::new_https_client(firewall.clone())?); - - let exec = Executor::graceful(guard); + .into_layer(super::client::new_https_client( + exec.clone(), + firewall.clone(), + )?); let http_server = HttpServer::auto(exec.clone()).service(Arc::new(https_svc)); diff --git a/proxy/src/storage/secrets.rs b/proxy/src/storage/secrets.rs index e4fd4e12..e8101677 100644 --- a/proxy/src/storage/secrets.rs +++ b/proxy/src/storage/secrets.rs @@ -62,6 +62,15 @@ impl SyncSecrets { } } +#[cfg(feature = "bench")] +impl SyncSecrets { + pub fn new_in_memory() -> Self { + Self(Backend::InMemory { + secrets: Arc::new(RwLock::new(HashMap::new())), + }) + } +} + impl FromStr for SyncSecrets { type Err = OpaqueError; diff --git a/proxy/src/test/e2e/test_proxy/report_blocked_events.rs b/proxy/src/test/e2e/test_proxy/report_blocked_events.rs index 9fa8d692..60d3425a 100644 --- a/proxy/src/test/e2e/test_proxy/report_blocked_events.rs +++ b/proxy/src/test/e2e/test_proxy/report_blocked_events.rs @@ -1,5 +1,6 @@ use rama::{ http::{BodyExtractExt as _, StatusCode, service::client::HttpClientExt as _}, + rt::Executor, telemetry::tracing, }; @@ -9,7 +10,7 @@ use crate::test::e2e; #[tokio::test] #[tracing_test::traced_test] async fn test_report_blocked_events_posts_json_to_endpoint() { - let capture_client = crate::client::new_web_client().unwrap(); + let capture_client = crate::client::new_web_client(Executor::default()).unwrap(); let resp = capture_client .get("http://assert-test.internal/blocked-events/clear") diff --git a/proxy/src/tls/mod.rs b/proxy/src/tls/mod.rs index c440ede0..94d48cca 100644 --- a/proxy/src/tls/mod.rs +++ b/proxy/src/tls/mod.rs @@ -28,6 +28,11 @@ struct PemKeyCrtPair { pub struct RootCA(Arc>); impl RootCA { + pub fn as_str(&self) -> &str { + let ca = self.0.expose_secret(); + ca.as_str() + } + pub fn as_http_response(&self) -> Response { let ca = self.0.expose_secret(); ca.clone().into_response() diff --git a/proxy/src/utils/env.rs b/proxy/src/utils/env.rs index 5f5b4be5..367b8242 100644 --- a/proxy/src/utils/env.rs +++ b/proxy/src/utils/env.rs @@ -1,3 +1,15 @@ pub const fn project_name() -> &'static str { env!("CARGO_PKG_NAME") } + +pub fn compute_concurrent_request_count() -> usize { + std::env::var("MAX_CONCURRENT_REQUESTS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or_else(|| { + let cpus = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1); + cpus * 64 + }) +} diff --git a/proxy_netbench/Cargo.toml b/proxy_netbench/Cargo.toml index 913e2dc7..de4fa4c5 100644 --- a/proxy_netbench/Cargo.toml +++ b/proxy_netbench/Cargo.toml @@ -13,7 +13,7 @@ clap = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["full"] } serde = { workspace = true } rand = { workspace = true } -safechain-proxy-lib = { workspace = true, features = ["har"] } +safechain-proxy-lib = { workspace = true, features = ["har", "bench"] } [target.'cfg(target_family = "unix")'.dependencies] jemallocator = { workspace = true } diff --git a/proxy_netbench/src/cmd/proxy/mod.rs b/proxy_netbench/src/cmd/proxy/mod.rs index f7c1c094..98ed8405 100644 --- a/proxy_netbench/src/cmd/proxy/mod.rs +++ b/proxy_netbench/src/cmd/proxy/mod.rs @@ -4,16 +4,21 @@ use rama::{ error::{ErrorContext as _, OpaqueError}, graceful::ShutdownGuard, http::Uri, - net::socket::Interface, + net::{address::SocketAddress, socket::Interface}, telemetry::tracing, }; use clap::Args; -use safechain_proxy_lib::{diagnostics, firewall, server, storage, tls}; +use safechain_proxy_lib::{client, diagnostics, firewall, server, storage, tls}; #[derive(Debug, Clone, Args)] /// run proxy in function of benchmarker pub struct ProxyCommand { + /// socket address of the mock server to be used + /// by proxy for all egress connections + #[arg(value_name = "ADDRESS", required = true)] + pub mock: SocketAddress, + /// network interface to bind to #[arg( long, @@ -35,9 +40,11 @@ pub struct ProxyCommand { pub async fn exec( data: PathBuf, guard: ShutdownGuard, - secrets: storage::SyncSecrets, args: ProxyCommand, ) -> Result<(), OpaqueError> { + tracing::info!(mock = %args.mock, "set mock server as egress address overwrite"); + client::set_egress_address_overwrite(args.mock); + tokio::fs::create_dir_all(&data) .await .with_context(|| format!("create data directory at path '{}'", data.display()))?; @@ -50,8 +57,12 @@ pub async fn exec( })?; tracing::info!(path = ?data, "data directory ready to be used"); - let (tls_acceptor, _root_ca) = + let secrets = storage::SyncSecrets::new_in_memory(); + + let (tls_acceptor, root_ca) = tls::new_tls_acceptor_layer(&secrets, &data_storage).context("prepare TLS acceptor")?; + tracing::info!(path = ?data, "write new (tmp) root CA to disk"); + server::write_root_ca_as_file(&data, &root_ca).await?; // ensure to not wait for firewall creation in case shutdown was initiated, // this can happen for example in case remote lists need to be fetched and the diff --git a/proxy_netbench/src/main.rs b/proxy_netbench/src/main.rs index 63aabbe4..37aacc7a 100644 --- a/proxy_netbench/src/main.rs +++ b/proxy_netbench/src/main.rs @@ -7,7 +7,7 @@ use rama::{ }; use clap::{Parser, Subcommand}; -use safechain_proxy_lib::{storage, utils}; +use safechain_proxy_lib::utils; pub mod cmd; pub mod config; @@ -54,14 +54,6 @@ pub struct Args { )] pub data: PathBuf, - /// secrets storage to use (e.g. for root CA) - #[arg( - long, - value_name = "keyring | memory | ", - default_value = "keyring" - )] - pub secrets: storage::SyncSecrets, - #[arg(long, value_name = "SECONDS", default_value_t = 0., global = true)] /// the graceful shutdown timeout (<= 0.0 = no timeout) pub graceful: f64, @@ -109,7 +101,7 @@ where CliCommands::Run(run_args) => self::cmd::run::exec(args.data, run_args).await, CliCommands::Mock(mock_args) => self::cmd::mock::exec(mock_args).await, CliCommands::Proxy(proxy_args) => { - self::cmd::proxy::exec(args.data, guard, args.secrets, proxy_args).await + self::cmd::proxy::exec(args.data, guard, proxy_args).await } }; if let Err(err) = result { From 0b633ff47e432d24983a2a94e8ce498d146b5b37 Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 23 Jan 2026 11:05:56 +0100 Subject: [PATCH 09/52] remove unused deps (in netbench) --- Cargo.lock | 1 - proxy_netbench/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7367b0df..985f0410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1586,7 +1586,6 @@ dependencies = [ "rama", "rand", "safechain-proxy-lib", - "serde", "tikv-jemallocator", "tokio", ] diff --git a/proxy_netbench/Cargo.toml b/proxy_netbench/Cargo.toml index de4fa4c5..764872be 100644 --- a/proxy_netbench/Cargo.toml +++ b/proxy_netbench/Cargo.toml @@ -11,7 +11,6 @@ resolver = "3" [dependencies] clap = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["full"] } -serde = { workspace = true } rand = { workspace = true } safechain-proxy-lib = { workspace = true, features = ["har", "bench"] } From 59b386c768b084784ed8fd1b20b2f77c337b7360 Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 23 Jan 2026 11:10:34 +0100 Subject: [PATCH 10/52] improve numbers for bench runs --- proxy_netbench/src/config/scenario.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/proxy_netbench/src/config/scenario.rs b/proxy_netbench/src/config/scenario.rs index 2b519dad..b1fe26c0 100644 --- a/proxy_netbench/src/config/scenario.rs +++ b/proxy_netbench/src/config/scenario.rs @@ -1,3 +1,5 @@ +use safechain_proxy_lib::utils; + use super::{ClientConfig, ServerConfig}; /// High level benchmark scenarios. @@ -25,9 +27,10 @@ impl Scenario { match self { Scenario::Baseline => { // Smooth request generation with no randomness. + let concurrency = utils::env::compute_concurrent_request_count() as u32; ClientConfig { - target_rps: Some(1000), - concurrency: Some(10), + target_rps: Some(50 * concurrency), + concurrency: Some(concurrency), jitter: None, burst_size: Some(1), } @@ -37,8 +40,8 @@ impl Scenario { // Requests are sent at an uneven pace. // This introduces burstiness and queue formation. ClientConfig { - target_rps: Some(1000), - concurrency: Some(20), + target_rps: Some(5000), + concurrency: Some(100), jitter: Some(0.005), burst_size: Some(2), } @@ -47,8 +50,8 @@ impl Scenario { Scenario::FlakyUpstream => { // Client side jitter is higher to simulate unstable producers. ClientConfig { - target_rps: Some(600), - concurrency: Some(25), + target_rps: Some(2500), + concurrency: Some(50), jitter: Some(0.01), burst_size: Some(2), } From cb77b1c5fd82eab2a1788b777c28f9d54dcfa20d Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 23 Jan 2026 11:40:52 +0100 Subject: [PATCH 11/52] add tests for newly introduced utils --- proxy/src/firewall/domain_matcher.rs | 84 +++++++++++++++++++++++++++- proxy_netbench/src/config/product.rs | 51 +++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/proxy/src/firewall/domain_matcher.rs b/proxy/src/firewall/domain_matcher.rs index 91239072..94a100db 100644 --- a/proxy/src/firewall/domain_matcher.rs +++ b/proxy/src/firewall/domain_matcher.rs @@ -1,5 +1,6 @@ use rama::net::address::{AsDomainRef, Domain, DomainParentMatch, DomainTrie}; +#[derive(Debug)] pub(super) struct DomainMatcher(DomainTrie); impl DomainMatcher { @@ -32,6 +33,15 @@ impl FromIterator for DomainMatcher { { domains.insert_domain(parent, DomainAllowMode::Parent); } else { + if domains + .match_parent(&domain) + .map(|m| *m.value == DomainAllowMode::Parent) + .unwrap_or_default() + { + // ignore exact mode if already a parent-mode exists for the key + // in order to prevent accidental collisions. + continue; + } domains.insert_domain(domain, DomainAllowMode::Exact); } } @@ -39,8 +49,80 @@ impl FromIterator for DomainMatcher { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DomainAllowMode { Exact, Parent, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_domain_matcher_empty() { + let matcher = >::from_iter([]); + assert!(matcher.iter().next().is_none()); + assert!(!matcher.is_match(&Domain::example())); + } + + #[test] + fn test_domain_matcher_exact() { + let matcher: DomainMatcher = ["example.com", "aikido.dev"].into_iter().collect(); + + let mut domains: Vec<_> = matcher.iter().map(|d| d.to_string()).collect(); + assert_eq!(2, domains.len()); + domains.sort(); + assert_eq!("aikido.dev", domains[0]); + assert_eq!("example.com", domains[1]); + + assert!(matcher.is_match(&Domain::example())); + assert!(matcher.is_match(&Domain::from_static("aikido.dev"))); + assert!(!matcher.is_match(&Domain::from_static("cdn.aikido.dev"))); + assert!(!matcher.is_match(&Domain::from_static("foo.bar"))); + } + + fn test_domain_matcher_inner(matcher: DomainMatcher) { + let mut domains: Vec<_> = matcher.iter().map(|d| d.to_string()).collect(); + assert_eq!(2, domains.len()); + domains.sort(); + assert_eq!("aikido.dev", domains[0]); + assert_eq!("example.com", domains[1]); + + assert!(matcher.is_match(&Domain::example())); + assert!(matcher.is_match(&Domain::from_static("aikido.dev"))); + assert!(matcher.is_match(&Domain::from_static("cdn.aikido.dev"))); + assert!(!matcher.is_match(&Domain::from_static("foo.example.com"))); + assert!(!matcher.is_match(&Domain::from_static("foo.bar"))); + } + + #[test] + fn test_domain_matcher_parent() { + let matcher: DomainMatcher = ["example.com", "*.aikido.dev"].into_iter().collect(); + test_domain_matcher_inner(matcher); + } + + #[test] + fn test_domain_matcher_parent_collide() { + let matcher: DomainMatcher = ["example.com", "*.aikido.dev", "aikido.dev"] + .into_iter() + .collect(); + test_domain_matcher_inner(matcher); + } + + #[test] + fn test_domain_matcher_parent_collide_rev() { + let matcher: DomainMatcher = ["example.com", "aikido.dev", "*.aikido.dev"] + .into_iter() + .collect(); + test_domain_matcher_inner(matcher); + } + + #[test] + fn test_domain_matcher_parent_collide_dup() { + let matcher: DomainMatcher = ["example.com", "*.aikido.dev", "*.aikido.dev"] + .into_iter() + .collect(); + test_domain_matcher_inner(matcher); + } +} diff --git a/proxy_netbench/src/config/product.rs b/proxy_netbench/src/config/product.rs index 3a2a8321..c8d5d1a6 100644 --- a/proxy_netbench/src/config/product.rs +++ b/proxy_netbench/src/config/product.rs @@ -266,3 +266,54 @@ fn shared_download_client() -> BoxService { CLIENT.clone() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_product_values() { + for (input, expected) in [ + ( + "", + Some(non_empty_vec![QualityValue::new_value(Product::Unknown( + "".to_owned() + ))]), + ), + ( + ";q=0.42", + Some(non_empty_vec![QualityValue::new( + Product::Unknown("".to_owned()), + Quality::new_clamped(420) + )]), + ), + ( + "-", + Some(non_empty_vec![QualityValue::new_value(Product::None)]), + ), + ( + "-; q=0.1", + Some(non_empty_vec![QualityValue::new( + Product::None, + Quality::new_clamped(100) + )]), + ), + ( + "none; q=0.8, vscode; q=0.2", + Some(non_empty_vec![ + QualityValue::new(Product::None, Quality::new_clamped(800)), + QualityValue::new(Product::VSCode, Quality::new_clamped(200)) + ]), + ), + ] { + let result = parse_product_values(input); + match (result, expected) { + (Ok(result), Some(expected)) => assert_eq!(result, expected, "input: '{input}'"), + (Err(_), None) => (), + (result, expected) => panic!( + "input = '{input}', unexpected result '{result:?}', expected: '{expected:?}'" + ), + } + } + } +} From 17c2e06b9df797086c6b3afbc08dd325e210ebf3 Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 23 Jan 2026 18:02:33 +0100 Subject: [PATCH 12/52] add+support netbench mock server impl --- proxy/src/client/mod.rs | 16 +- proxy_netbench/src/cmd/mock/mod.rs | 237 ++++++++++++++++++++++++++++- 2 files changed, 247 insertions(+), 6 deletions(-) diff --git a/proxy/src/client/mod.rs b/proxy/src/client/mod.rs index 18cb3a06..24f405ba 100644 --- a/proxy/src/client/mod.rs +++ b/proxy/src/client/mod.rs @@ -12,8 +12,11 @@ #[cfg(all(not(test), feature = "bench"))] use ::{ parking_lot::Mutex, - rama::{combinators::Either, net::address::SocketAddress}, - std::sync::LazyLock, + rama::{ + combinators::Either, net::address::SocketAddress, net::tls::client::ServerVerifyMode, + tls::boring::client::TlsConnectorDataBuilder, + }, + std::sync::{Arc, LazyLock}, }; #[cfg(not(test))] use ::{ @@ -60,13 +63,20 @@ pub fn new_web_client( None => tcp_connector.with_connector(Either::B(())), }; + #[cfg(not(all(not(test), feature = "bench")))] + let tls_config = None; + #[cfg(all(not(test), feature = "bench"))] + let tls_config = Some(Arc::new( + TlsConnectorDataBuilder::new_http_auto().with_server_verify_mode(ServerVerifyMode::Disable), + )); + Ok(EasyHttpWebClient::connector_builder() .with_custom_transport_connector(tcp_connector) .without_tls_proxy_support() .without_proxy_support() // fallback to HTTP/1.1 as default HTTP version in case // no protocol negotation happens on layers such as TLS (e.g. ALPN) - .with_tls_support_using_boringssl_and_default_http_version(None, Version::HTTP_11) + .with_tls_support_using_boringssl_and_default_http_version(tls_config, Version::HTTP_11) .with_default_http_connector(Executor::default()) .try_with_connection_pool(HttpPooledConnectorConfig { max_total, diff --git a/proxy_netbench/src/cmd/mock/mod.rs b/proxy_netbench/src/cmd/mock/mod.rs index 1be3ea9a..c120010e 100644 --- a/proxy_netbench/src/cmd/mock/mod.rs +++ b/proxy_netbench/src/cmd/mock/mod.rs @@ -1,5 +1,33 @@ +use std::{convert::Infallible, path::PathBuf, sync::Arc, time::Duration}; + +use rama::{ + Layer as _, Service, + error::{ErrorContext as _, OpaqueError}, + graceful::ShutdownGuard, + http::{ + Body, HeaderValue, Request, Response, StatusCode, + headers::ContentType, + layer::{required_header::AddRequiredResponseHeadersLayer, trace::TraceLayer}, + server::HttpServer, + service::web::response::{Headers, IntoResponse}, + }, + layer::TimeoutLayer, + net::{ + socket::Interface, + tls::{ + self, ApplicationProtocol, + server::{SelfSignedData, ServerAuth, TlsPeekRouter}, + }, + }, + rt::Executor, + tcp::server::TcpListener, + telemetry::tracing, + tls::boring::server::{TlsAcceptorData, TlsAcceptorLayer}, +}; + use clap::Args; -use rama::{error::OpaqueError, net::socket::Interface, telemetry::tracing}; +use safechain_proxy_lib::{server, utils}; +use tokio::sync::mpsc; use crate::config::{Scenario, ServerConfig}; @@ -24,12 +52,215 @@ pub struct MockCommand { pub bind: Interface, } -pub async fn exec(args: MockCommand) -> Result<(), OpaqueError> { - let _merged_cfg = merge_server_cfg(args); +pub async fn exec( + data: PathBuf, + guard: ShutdownGuard, + args: MockCommand, +) -> Result<(), OpaqueError> { + tokio::fs::create_dir_all(&data) + .await + .with_context(|| format!("create data directory at path '{}'", data.display()))?; + + let exec = Executor::graceful(guard); + let tcp_listener = TcpListener::bind(args.bind.clone(), exec.clone()) + .await + .map_err(OpaqueError::from_boxed) + .context("bind proxy meta http(s) server")?; + + let merged_cfg = merge_server_cfg(args); + + // TODO: use + perhaps something better than mpsc unbounded?? + + let (drop_tcp_connection_tx, drop_tcp_connection_rx) = mpsc::unbounded_channel(); + + let http_svc = ( + TraceLayer::new_for_http(), + AddRequiredResponseHeadersLayer::new() + .with_server_header_value(HeaderValue::from_static(utils::env::project_name())), + ) + .into_layer(Arc::new(MockHttpServer::try_new( + merged_cfg, + drop_tcp_connection_tx, + )?)); + + let http_server = HttpServer::auto(exec).service(Arc::new(http_svc)); + + let tls_acceptor = TlsAcceptorLayer::new(try_new_tls_self_signed_server_data()?); + + let tcp_svc = TimeoutLayer::new(Duration::from_secs(60)).into_layer( + TlsPeekRouter::new(tls_acceptor.into_layer(http_server.clone())).with_fallback(http_server), + ); + + let server_addr = tcp_listener + .local_addr() + .context("get bound address for mock http(s) server")?; + server::write_server_socket_address_as_file(&data, "netbench.mock", server_addr.into()).await?; + + tcp_listener.serve(tcp_svc).await; Ok(()) } +fn try_new_tls_self_signed_server_data() -> Result { + let tls_server_config = tls::server::ServerConfig { + application_layer_protocol_negotiation: Some(vec![ + ApplicationProtocol::HTTP_2, + ApplicationProtocol::HTTP_11, + ]), + ..tls::server::ServerConfig::new(ServerAuth::SelfSigned(SelfSignedData { + organisation_name: Some("netbench mock server".to_owned()), + ..Default::default() + })) + }; + tls_server_config + .try_into() + .context("create tls server config") +} + +#[derive(Debug)] +struct MockHttpServer { + base_latency: f64, + jitter: f64, + error_rate: f32, + drop_rate: f32, + timeout_rate: f32, + drop_tcp_connection: mpsc::UnboundedSender<()>, +} + +impl MockHttpServer { + fn try_new( + cfg: ServerConfig, + drop_tcp_connection: mpsc::UnboundedSender<()>, + ) -> Result { + let base_latency = cfg.base_latency.unwrap_or_default(); + let jitter = cfg.jitter.unwrap_or_default(); + let error_rate = cfg.error_rate.unwrap_or_default(); + let drop_rate = cfg.drop_rate.unwrap_or_default(); + let timeout_rate = cfg.timeout_rate.unwrap_or_default(); + + let sum = drop_rate + timeout_rate + error_rate; + if sum > 1. { + return Err(OpaqueError::from_display( + "drop_rate + timeout_rate + error_rate must be <= 1.0", + )); + } + + Ok(Self { + base_latency, + jitter, + error_rate, + drop_rate, + timeout_rate, + drop_tcp_connection, + }) + } + + #[inline(always)] + fn clamp_rate(v: f32) -> f32 { + v.clamp(0., 1.0) + } + + fn pick_outcome(&self) -> MockOutcome { + let drop_rate = Self::clamp_rate(self.drop_rate); + let timeout_rate = Self::clamp_rate(self.timeout_rate); + let error_rate = Self::clamp_rate(self.error_rate); + + let r: f32 = rand::random(); + + let t_drop = drop_rate; + let t_timeout = t_drop + timeout_rate; + let t_error = t_timeout + error_rate; + + if r < t_drop { + MockOutcome::Drop + } else if r < t_timeout { + MockOutcome::Timeout + } else if r < t_error { + MockOutcome::Error + } else { + MockOutcome::Ok + } + } + + fn compute_delay(&self) -> std::time::Duration { + let base = self.base_latency.max(0.0); + let jitter = self.jitter.max(0.0); + + if jitter == 0.0 { + return std::time::Duration::from_secs_f64(base); + } + + let span = jitter * 2.0; + let u: f64 = rand::random(); + let delta = (u * span) - jitter; + + let secs = (base + delta).max(0.0); + std::time::Duration::from_secs_f64(secs) + } + + fn random_ok_body(uri: &rama::http::Uri) -> Body { + let mut h = std::collections::hash_map::DefaultHasher::new(); + std::hash::Hash::hash(&uri, &mut h); + let multiplier = (std::hash::Hasher::finish(&h) as u32) % 6; + + rama::http::body::InfiniteReader::new() + .with_size_limit(2usize.pow(multiplier) * 512) + .into_body() + } + + fn random_ok_response(req: &Request) -> Response { + let body = Self::random_ok_body(req.uri()); + ( + StatusCode::OK, + Headers::single(ContentType::octet_stream()), + body, + ) + .into_response() + } + + fn error_response() -> Response { + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } +} + +#[derive(Debug, Clone, Copy)] +enum MockOutcome { + Drop, + Timeout, + Error, + Ok, +} + +impl Service for MockHttpServer { + type Output = Response; + type Error = Infallible; + + async fn serve(&self, req: Request) -> Result { + let delay = self.compute_delay(); + if delay.as_nanos() > 0 { + tokio::time::sleep(delay).await; + } + + Ok(match self.pick_outcome() { + MockOutcome::Drop => { + if let Err(err) = self.drop_tcp_connection.send(()) { + tracing::error!("failed to send MockFail::DropConnection: {err}"); + } + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + MockOutcome::Timeout => StatusCode::REQUEST_TIMEOUT.into_response(), + MockOutcome::Error => Self::error_response(), + MockOutcome::Ok => Self::random_ok_response(&req), + }) + } +} + +#[derive(Debug)] +struct MockTcpServer { + inner: S, + drop_tcp_connection: mpsc::UnboundedReceiver<()>, +} + fn merge_server_cfg(args: MockCommand) -> ServerConfig { let scenario_cfg = args .scenario From 1dc890f79c4b8d376d92a674990484daa07ba714 Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 24 Jan 2026 00:17:23 +0100 Subject: [PATCH 13/52] finish off mock server netbench --- proxy_netbench/src/cmd/mock/mod.rs | 42 +++++++++++------------------- proxy_netbench/src/main.rs | 4 ++- 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/proxy_netbench/src/cmd/mock/mod.rs b/proxy_netbench/src/cmd/mock/mod.rs index c120010e..862aaf8a 100644 --- a/proxy_netbench/src/cmd/mock/mod.rs +++ b/proxy_netbench/src/cmd/mock/mod.rs @@ -3,6 +3,7 @@ use std::{convert::Infallible, path::PathBuf, sync::Arc, time::Duration}; use rama::{ Layer as _, Service, error::{ErrorContext as _, OpaqueError}, + extensions::ExtensionsRef, graceful::ShutdownGuard, http::{ Body, HeaderValue, Request, Response, StatusCode, @@ -11,7 +12,7 @@ use rama::{ server::HttpServer, service::web::response::{Headers, IntoResponse}, }, - layer::TimeoutLayer, + layer::{AbortableLayer, TimeoutLayer, abort::AbortController}, net::{ socket::Interface, tls::{ @@ -27,7 +28,6 @@ use rama::{ use clap::Args; use safechain_proxy_lib::{server, utils}; -use tokio::sync::mpsc; use crate::config::{Scenario, ServerConfig}; @@ -69,27 +69,25 @@ pub async fn exec( let merged_cfg = merge_server_cfg(args); - // TODO: use + perhaps something better than mpsc unbounded?? - - let (drop_tcp_connection_tx, drop_tcp_connection_rx) = mpsc::unbounded_channel(); - let http_svc = ( TraceLayer::new_for_http(), AddRequiredResponseHeadersLayer::new() .with_server_header_value(HeaderValue::from_static(utils::env::project_name())), ) - .into_layer(Arc::new(MockHttpServer::try_new( - merged_cfg, - drop_tcp_connection_tx, - )?)); + .into_layer(Arc::new(MockHttpServer::try_new(merged_cfg)?)); let http_server = HttpServer::auto(exec).service(Arc::new(http_svc)); let tls_acceptor = TlsAcceptorLayer::new(try_new_tls_self_signed_server_data()?); - let tcp_svc = TimeoutLayer::new(Duration::from_secs(60)).into_layer( - TlsPeekRouter::new(tls_acceptor.into_layer(http_server.clone())).with_fallback(http_server), - ); + let tcp_svc = ( + AbortableLayer::new(), + TimeoutLayer::new(Duration::from_secs(60)), + ) + .into_layer( + TlsPeekRouter::new(tls_acceptor.into_layer(http_server.clone())) + .with_fallback(http_server), + ); let server_addr = tcp_listener .local_addr() @@ -124,14 +122,10 @@ struct MockHttpServer { error_rate: f32, drop_rate: f32, timeout_rate: f32, - drop_tcp_connection: mpsc::UnboundedSender<()>, } impl MockHttpServer { - fn try_new( - cfg: ServerConfig, - drop_tcp_connection: mpsc::UnboundedSender<()>, - ) -> Result { + fn try_new(cfg: ServerConfig) -> Result { let base_latency = cfg.base_latency.unwrap_or_default(); let jitter = cfg.jitter.unwrap_or_default(); let error_rate = cfg.error_rate.unwrap_or_default(); @@ -151,7 +145,6 @@ impl MockHttpServer { error_rate, drop_rate, timeout_rate, - drop_tcp_connection, }) } @@ -243,9 +236,10 @@ impl Service for MockHttpServer { Ok(match self.pick_outcome() { MockOutcome::Drop => { - if let Err(err) = self.drop_tcp_connection.send(()) { - tracing::error!("failed to send MockFail::DropConnection: {err}"); + if let Some(controller) = req.extensions().get::() { + controller.abort().await; } + tracing::error!("failed to abort connection via controller"); StatusCode::INTERNAL_SERVER_ERROR.into_response() } MockOutcome::Timeout => StatusCode::REQUEST_TIMEOUT.into_response(), @@ -255,12 +249,6 @@ impl Service for MockHttpServer { } } -#[derive(Debug)] -struct MockTcpServer { - inner: S, - drop_tcp_connection: mpsc::UnboundedReceiver<()>, -} - fn merge_server_cfg(args: MockCommand) -> ServerConfig { let scenario_cfg = args .scenario diff --git a/proxy_netbench/src/main.rs b/proxy_netbench/src/main.rs index 37aacc7a..f7c855bd 100644 --- a/proxy_netbench/src/main.rs +++ b/proxy_netbench/src/main.rs @@ -99,7 +99,9 @@ where graceful.spawn_task_fn(async move |guard| { let result = match args.cmds { CliCommands::Run(run_args) => self::cmd::run::exec(args.data, run_args).await, - CliCommands::Mock(mock_args) => self::cmd::mock::exec(mock_args).await, + CliCommands::Mock(mock_args) => { + self::cmd::mock::exec(args.data, guard, mock_args).await + } CliCommands::Proxy(proxy_args) => { self::cmd::proxy::exec(args.data, guard, proxy_args).await } From 27139d39f1b97d65bc352b32c3e990ad18c3d5fa Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 24 Jan 2026 00:19:13 +0100 Subject: [PATCH 14/52] improve proxy mock address support --- proxy/src/client/mod.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/proxy/src/client/mod.rs b/proxy/src/client/mod.rs index 24f405ba..0bfabc0e 100644 --- a/proxy/src/client/mod.rs +++ b/proxy/src/client/mod.rs @@ -56,20 +56,20 @@ pub fn new_web_client( let max_total = max_active * 2; let tcp_connector = TcpConnector::new(exec); + let mut tls_config = None; #[cfg(all(not(test), feature = "bench"))] let tcp_connector = match *EGRESS_ADDRESS_OVERWRITE.lock() { - Some(value) => tcp_connector.with_connector(Either::A(value)), + Some(value) => { + tls_config = Some(Arc::new( + TlsConnectorDataBuilder::new_http_auto() + .with_server_verify_mode(ServerVerifyMode::Disable), + )); + tcp_connector.with_connector(Either::A(value)) + } None => tcp_connector.with_connector(Either::B(())), }; - #[cfg(not(all(not(test), feature = "bench")))] - let tls_config = None; - #[cfg(all(not(test), feature = "bench"))] - let tls_config = Some(Arc::new( - TlsConnectorDataBuilder::new_http_auto().with_server_verify_mode(ServerVerifyMode::Disable), - )); - Ok(EasyHttpWebClient::connector_builder() .with_custom_transport_connector(tcp_connector) .without_tls_proxy_support() From de83f709f0d8f5f3a79215843bd58ac9b04560ae Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 24 Jan 2026 11:03:27 +0100 Subject: [PATCH 15/52] pre-generate ok payloads for mock server --- proxy/src/utils/env.rs | 4 +++ proxy_netbench/src/cmd/mock/mod.rs | 57 +++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/proxy/src/utils/env.rs b/proxy/src/utils/env.rs index 367b8242..76490401 100644 --- a/proxy/src/utils/env.rs +++ b/proxy/src/utils/env.rs @@ -2,6 +2,10 @@ pub const fn project_name() -> &'static str { env!("CARGO_PKG_NAME") } +pub const fn server_identifier() -> &'static str { + concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")) +} + pub fn compute_concurrent_request_count() -> usize { std::env::var("MAX_CONCURRENT_REQUESTS") .ok() diff --git a/proxy_netbench/src/cmd/mock/mod.rs b/proxy_netbench/src/cmd/mock/mod.rs index 862aaf8a..651319ec 100644 --- a/proxy_netbench/src/cmd/mock/mod.rs +++ b/proxy_netbench/src/cmd/mock/mod.rs @@ -6,9 +6,13 @@ use rama::{ extensions::ExtensionsRef, graceful::ShutdownGuard, http::{ - Body, HeaderValue, Request, Response, StatusCode, + Body, HeaderName, HeaderValue, InfiniteReader, Request, Response, StatusCode, Uri, + body::util::BodyExt, headers::ContentType, - layer::{required_header::AddRequiredResponseHeadersLayer, trace::TraceLayer}, + layer::{ + compression::CompressionLayer, required_header::AddRequiredResponseHeadersLayer, + trace::TraceLayer, + }, server::HttpServer, service::web::response::{Headers, IntoResponse}, }, @@ -24,6 +28,7 @@ use rama::{ tcp::server::TcpListener, telemetry::tracing, tls::boring::server::{TlsAcceptorData, TlsAcceptorLayer}, + utils::str::smol_str::ToSmolStr, }; use clap::Args; @@ -71,10 +76,11 @@ pub async fn exec( let http_svc = ( TraceLayer::new_for_http(), + CompressionLayer::new(), AddRequiredResponseHeadersLayer::new() - .with_server_header_value(HeaderValue::from_static(utils::env::project_name())), + .with_server_header_value(HeaderValue::from_static(utils::env::server_identifier())), ) - .into_layer(Arc::new(MockHttpServer::try_new(merged_cfg)?)); + .into_layer(Arc::new(MockHttpServer::try_new(merged_cfg).await?)); let http_server = HttpServer::auto(exec).service(Arc::new(http_svc)); @@ -92,8 +98,11 @@ pub async fn exec( let server_addr = tcp_listener .local_addr() .context("get bound address for mock http(s) server")?; + + // write the address right before serving server::write_server_socket_address_as_file(&data, "netbench.mock", server_addr.into()).await?; + tracing::info!("mock server ready to serve @ {server_addr}"); tcp_listener.serve(tcp_svc).await; Ok(()) @@ -122,10 +131,11 @@ struct MockHttpServer { error_rate: f32, drop_rate: f32, timeout_rate: f32, + ok_payloads: Vec<&'static [u8]>, } impl MockHttpServer { - fn try_new(cfg: ServerConfig) -> Result { + async fn try_new(cfg: ServerConfig) -> Result { let base_latency = cfg.base_latency.unwrap_or_default(); let jitter = cfg.jitter.unwrap_or_default(); let error_rate = cfg.error_rate.unwrap_or_default(); @@ -139,12 +149,30 @@ impl MockHttpServer { )); } + tracing::info!("generating random OK payloads..."); + + let mut ok_payloads: Vec<&'static [u8]> = Vec::with_capacity(8); + for multiplier in 0..6 { + let payload = InfiniteReader::new() + .with_size_limit(2usize.pow(multiplier as u32) * 512) + .into_body() + .collect() + .await + .context("read generated random body")? + .to_bytes(); + ok_payloads.push(payload.to_vec().leak()); + } + // compressible payloads + ok_payloads.push(include_bytes!("./mod.rs")); + ok_payloads.push(include_bytes!("../../../Cargo.toml")); + Ok(Self { base_latency, jitter, error_rate, drop_rate, timeout_rate, + ok_payloads, }) } @@ -191,21 +219,24 @@ impl MockHttpServer { std::time::Duration::from_secs_f64(secs) } - fn random_ok_body(uri: &rama::http::Uri) -> Body { + fn random_ok_body(&self, uri: &Uri) -> (usize, Body) { let mut h = std::collections::hash_map::DefaultHasher::new(); std::hash::Hash::hash(&uri, &mut h); - let multiplier = (std::hash::Hasher::finish(&h) as u32) % 6; + let index = (std::hash::Hasher::finish(&h) as usize) % self.ok_payloads.len(); - rama::http::body::InfiniteReader::new() - .with_size_limit(2usize.pow(multiplier) * 512) - .into_body() + (index, Body::from(self.ok_payloads[index])) } - fn random_ok_response(req: &Request) -> Response { - let body = Self::random_ok_body(req.uri()); + fn random_ok_response(&self, req: &Request) -> Response { + let (index, body) = self.random_ok_body(req.uri()); + let index_str = index.to_smolstr(); ( StatusCode::OK, Headers::single(ContentType::octet_stream()), + [( + HeaderName::from_static("x-mock-response-random"), + HeaderValue::from_str(&index_str).expect("ascii number to be valid header"), + )], body, ) .into_response() @@ -244,7 +275,7 @@ impl Service for MockHttpServer { } MockOutcome::Timeout => StatusCode::REQUEST_TIMEOUT.into_response(), MockOutcome::Error => Self::error_response(), - MockOutcome::Ok => Self::random_ok_response(&req), + MockOutcome::Ok => self.random_ok_response(&req), }) } } From 1807f29ed24a20c86efcea6a3b3882a1eb3e71fb Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 24 Jan 2026 14:29:53 +0100 Subject: [PATCH 16/52] ensure forward traffic is also going to mock server --- proxy/src/client/mod.rs | 38 +-------- proxy/src/client/transport.rs | 120 ++++++++++++++++++++++++++++ proxy/src/server/proxy/server.rs | 9 ++- proxy_netbench/src/cmd/proxy/mod.rs | 4 +- 4 files changed, 131 insertions(+), 40 deletions(-) create mode 100644 proxy/src/client/transport.rs diff --git a/proxy/src/client/mod.rs b/proxy/src/client/mod.rs index 0bfabc0e..383a82e1 100644 --- a/proxy/src/client/mod.rs +++ b/proxy/src/client/mod.rs @@ -9,15 +9,6 @@ //! instead of rama/thirdparty clients as to ensure //! e2e test suites do not make actual external network requests. -#[cfg(all(not(test), feature = "bench"))] -use ::{ - parking_lot::Mutex, - rama::{ - combinators::Either, net::address::SocketAddress, net::tls::client::ServerVerifyMode, - tls::boring::client::TlsConnectorDataBuilder, - }, - std::sync::{Arc, LazyLock}, -}; #[cfg(not(test))] use ::{ rama::{ @@ -26,7 +17,6 @@ use ::{ http::{Request, Response, Version, client::EasyHttpWebClient}, net::client::pool::http::HttpPooledConnectorConfig, rt::Executor, - tcp::client::service::TcpConnector, }, std::time::Duration, }; @@ -37,15 +27,7 @@ mod mock_client; #[cfg(test)] pub use self::mock_client::new_mock_client as new_web_client; -#[cfg(all(not(test), feature = "bench"))] -static EGRESS_ADDRESS_OVERWRITE: LazyLock>> = - LazyLock::new(Default::default); - -#[cfg(all(not(test), feature = "bench"))] -pub fn set_egress_address_overwrite(address: SocketAddress) { - let mut overwrite = EGRESS_ADDRESS_OVERWRITE.lock(); - *overwrite = Some(address); -} +pub mod transport; /// Create a new web client that can be cloned and shared. #[cfg(not(test))] @@ -55,20 +37,8 @@ pub fn new_web_client( let max_active = crate::utils::env::compute_concurrent_request_count(); let max_total = max_active * 2; - let tcp_connector = TcpConnector::new(exec); - let mut tls_config = None; - - #[cfg(all(not(test), feature = "bench"))] - let tcp_connector = match *EGRESS_ADDRESS_OVERWRITE.lock() { - Some(value) => { - tls_config = Some(Arc::new( - TlsConnectorDataBuilder::new_http_auto() - .with_server_verify_mode(ServerVerifyMode::Disable), - )); - tcp_connector.with_connector(Either::A(value)) - } - None => tcp_connector.with_connector(Either::B(())), - }; + let tcp_connector = self::transport::new_tcp_connector(exec.clone()); + let tls_config = self::transport::new_tls_connector_config(); Ok(EasyHttpWebClient::connector_builder() .with_custom_transport_connector(tcp_connector) @@ -77,7 +47,7 @@ pub fn new_web_client( // fallback to HTTP/1.1 as default HTTP version in case // no protocol negotation happens on layers such as TLS (e.g. ALPN) .with_tls_support_using_boringssl_and_default_http_version(tls_config, Version::HTTP_11) - .with_default_http_connector(Executor::default()) + .with_default_http_connector(exec) .try_with_connection_pool(HttpPooledConnectorConfig { max_total, max_active, diff --git a/proxy/src/client/transport.rs b/proxy/src/client/transport.rs new file mode 100644 index 00000000..f6085145 --- /dev/null +++ b/proxy/src/client/transport.rs @@ -0,0 +1,120 @@ +use rama::{ + dns::GlobalDnsResolver, + rt::Executor, + tcp::{self, client::service::TcpStreamConnectorCloneFactory}, +}; + +pub type TcpConnector = tcp::client::service::TcpConnector< + GlobalDnsResolver, + TcpStreamConnectorCloneFactory, +>; + +pub fn new_tcp_connector(exec: Executor) -> TcpConnector { + tcp::client::service::TcpConnector::new(exec).with_connector(TcpStreamConnector::new()) +} + +#[cfg(not(any(test, feature = "bench")))] +mod production { + use std::sync::Arc; + + use rama::tls::boring::client::TlsConnectorDataBuilder; + + #[derive(Debug, Clone)] + pub struct TcpStreamConnector; + + impl TcpStreamConnector { + #[inline(always)] + pub(super) fn new() -> Self { + Self + } + } + + impl rama::tcp::client::TcpStreamConnector for TcpStreamConnector { + type Error = std::io::Error; + + fn connect( + &self, + addr: std::net::SocketAddr, + ) -> impl Future> + Send + '_ { + ().connect(addr) + } + } + + #[inline(always)] + pub fn new_tls_connector_config() -> Option> { + None + } +} + +#[cfg(not(any(test, feature = "bench")))] +pub use self::production::{TcpStreamConnector, new_tls_connector_config}; + +#[cfg(any(test, feature = "bench"))] +mod bench { + use std::sync::{Arc, OnceLock}; + + use rama::{ + error::OpaqueError, + net::{address::SocketAddress, tls::client::ServerVerifyMode}, + telemetry::tracing, + tls::boring::client::TlsConnectorDataBuilder, + }; + + static EGRESS_ADDRESS_OVERWRITE: OnceLock> = OnceLock::new(); + + pub fn try_set_egress_address_overwrite(address: SocketAddress) -> Result<(), OpaqueError> { + EGRESS_ADDRESS_OVERWRITE + .set(Some(address)) + .map_err(|v| OpaqueError::from_display(format!("egress address already set: {v:?}"))) + } + + fn get_egress_address_overwrite() -> Option { + *EGRESS_ADDRESS_OVERWRITE.get_or_init(Default::default) + } + + fn is_eggress_address_overwritten() -> bool { + get_egress_address_overwrite().is_some() + } + + #[derive(Debug, Clone)] + pub struct TcpStreamConnector(Option); + + impl TcpStreamConnector { + #[inline(always)] + pub(super) fn new() -> Self { + Self(get_egress_address_overwrite()) + } + } + + impl rama::tcp::client::TcpStreamConnector for TcpStreamConnector { + type Error = std::io::Error; + + async fn connect( + &self, + addr: std::net::SocketAddr, + ) -> Result { + match self.0 { + Some(overwrite_addr) => { + tracing::debug!("tcp connect addr = {addr} hijack w/ addr: {overwrite_addr}"); + ().connect(overwrite_addr.into()).await + } + None => ().connect(addr).await, + } + } + } + + #[inline(always)] + pub fn new_tls_connector_config() -> Option> { + is_eggress_address_overwritten().then(|| { + Arc::new( + TlsConnectorDataBuilder::new_http_auto() + .with_server_verify_mode(ServerVerifyMode::Disable), + ) + }) + } +} + +#[cfg(any(test, feature = "bench"))] +pub use self::bench::{ + TcpStreamConnector, new_tls_connector_config, try_set_egress_address_overwrite, +}; diff --git a/proxy/src/server/proxy/server.rs b/proxy/src/server/proxy/server.rs index b8a27ee4..1c8c0fd5 100644 --- a/proxy/src/server/proxy/server.rs +++ b/proxy/src/server/proxy/server.rs @@ -18,7 +18,7 @@ use rama::{ net::{proxy::ProxyTarget, tls::server::TlsPeekRouter}, rt::Executor, stream::Stream, - tcp::client::service::DefaultForwarder, + tcp::client::service::Forwarder, telemetry::tracing::{self, Level}, tls::boring::server::TlsAcceptorLayer, }; @@ -32,14 +32,14 @@ use rama::{ utils::str::arcstr::arcstr, }; -use crate::{firewall::Firewall, server::connectivity::CONNECTIVITY_DOMAIN}; +use crate::{client, firewall::Firewall, server::connectivity::CONNECTIVITY_DOMAIN}; #[derive(Debug, Clone)] pub(super) struct MitmServer { inner: S, mitm_all: bool, firewall: Firewall, - forwarder: DefaultForwarder, + forwarder: Forwarder, } #[derive(Debug, Clone)] @@ -87,7 +87,8 @@ pub(super) fn new_mitm_server( inner, mitm_all, firewall, - forwarder: DefaultForwarder::ctx(exec), + forwarder: Forwarder::ctx(exec.clone()) + .with_connector(client::transport::new_tcp_connector(exec)), }) } diff --git a/proxy_netbench/src/cmd/proxy/mod.rs b/proxy_netbench/src/cmd/proxy/mod.rs index 98ed8405..219071c9 100644 --- a/proxy_netbench/src/cmd/proxy/mod.rs +++ b/proxy_netbench/src/cmd/proxy/mod.rs @@ -42,8 +42,8 @@ pub async fn exec( guard: ShutdownGuard, args: ProxyCommand, ) -> Result<(), OpaqueError> { - tracing::info!(mock = %args.mock, "set mock server as egress address overwrite"); - client::set_egress_address_overwrite(args.mock); + tracing::info!(mock = %args.mock, "try set mock server as egress address overwrite"); + client::transport::try_set_egress_address_overwrite(args.mock)?; tokio::fs::create_dir_all(&data) .await From 434240e15e1c62d19da4f1f451113463fdaf5089 Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 24 Jan 2026 14:40:07 +0100 Subject: [PATCH 17/52] add large payload for mock test that is highly compressable --- proxy/src/firewall/malware_list.rs | 5 +++++ proxy/src/firewall/rule/npm.rs | 4 ++-- proxy/src/firewall/rule/pypi.rs | 6 +++-- proxy/src/firewall/rule/vscode/mod.rs | 4 ++-- proxy_netbench/src/cmd/mock/mod.rs | 32 ++++++++++++++++++++++----- proxy_netbench/src/config/mod.rs | 4 +++- proxy_netbench/src/config/product.rs | 23 +++++++++---------- 7 files changed, 53 insertions(+), 25 deletions(-) diff --git a/proxy/src/firewall/malware_list.rs b/proxy/src/firewall/malware_list.rs index c29c1cbe..32089b89 100644 --- a/proxy/src/firewall/malware_list.rs +++ b/proxy/src/firewall/malware_list.rs @@ -19,6 +19,11 @@ use tokio::time::Instant; use crate::storage::SyncCompactDataStorage; +pub const MALWARE_LIST_URI_STR_VSCODE: &str = "https://malware-list.aikido.dev/malware_vscode.json"; +pub const MALWARE_LIST_URI_STR_PYPI: &str = "https://malware-list.aikido.dev/malware_pypi.json"; +pub const MALWARE_LIST_URI_STR_NPM: &str = + "https://malware-list.aikido.dev/malware_predictions.json"; + #[derive(Clone)] pub struct RemoteMalwareList { trie: Arc>, diff --git a/proxy/src/firewall/rule/npm.rs b/proxy/src/firewall/rule/npm.rs index 356fbe97..be0bb558 100644 --- a/proxy/src/firewall/rule/npm.rs +++ b/proxy/src/firewall/rule/npm.rs @@ -14,7 +14,7 @@ use crate::{ firewall::{ DomainMatcher, events::{BlockedArtifact, BlockedEventInfo}, - malware_list::{MalwareEntry, PackageVersion, RemoteMalwareList}, + malware_list::{MALWARE_LIST_URI_STR_NPM, MalwareEntry, PackageVersion, RemoteMalwareList}, pac::PacScriptGenerator, }, http::response::generate_generic_blocked_response_for_req, @@ -43,7 +43,7 @@ impl RuleNpm { // so it only gets updated once let remote_malware_list = RemoteMalwareList::try_new( guard, - Uri::from_static("https://malware-list.aikido.dev/malware_predictions.json"), + Uri::from_static(MALWARE_LIST_URI_STR_NPM), sync_storage, remote_malware_list_https_client, ) diff --git a/proxy/src/firewall/rule/pypi.rs b/proxy/src/firewall/rule/pypi.rs index 65903249..8003eace 100644 --- a/proxy/src/firewall/rule/pypi.rs +++ b/proxy/src/firewall/rule/pypi.rs @@ -19,7 +19,9 @@ use crate::{ firewall::{ DomainMatcher, events::{BlockedArtifact, BlockedEventInfo}, - malware_list::{MalwareEntry, PackageVersion, RemoteMalwareList}, + malware_list::{ + MALWARE_LIST_URI_STR_PYPI, MalwareEntry, PackageVersion, RemoteMalwareList, + }, pac::PacScriptGenerator, }, http::response::generate_generic_blocked_response_for_req, @@ -59,7 +61,7 @@ impl RulePyPI { { let remote_malware_list = RemoteMalwareList::try_new( guard, - Uri::from_static("https://malware-list.aikido.dev/malware_pypi.json"), + Uri::from_static(MALWARE_LIST_URI_STR_PYPI), sync_storage, remote_malware_list_https_client, ) diff --git a/proxy/src/firewall/rule/vscode/mod.rs b/proxy/src/firewall/rule/vscode/mod.rs index 7cd2192f..8968353b 100644 --- a/proxy/src/firewall/rule/vscode/mod.rs +++ b/proxy/src/firewall/rule/vscode/mod.rs @@ -16,7 +16,7 @@ use crate::{ firewall::{ DomainMatcher, events::{BlockedArtifact, BlockedEventInfo}, - malware_list::RemoteMalwareList, + malware_list::{MALWARE_LIST_URI_STR_VSCODE, RemoteMalwareList}, pac::PacScriptGenerator, }, http::response::generate_malware_blocked_response_for_req, @@ -41,7 +41,7 @@ impl RuleVSCode { { let remote_malware_list = RemoteMalwareList::try_new( guard, - Uri::from_static("https://malware-list.aikido.dev/malware_vscode.json"), + Uri::from_static(MALWARE_LIST_URI_STR_VSCODE), sync_storage, remote_malware_list_https_client, ) diff --git a/proxy_netbench/src/cmd/mock/mod.rs b/proxy_netbench/src/cmd/mock/mod.rs index 651319ec..09a190b4 100644 --- a/proxy_netbench/src/cmd/mock/mod.rs +++ b/proxy_netbench/src/cmd/mock/mod.rs @@ -32,9 +32,11 @@ use rama::{ }; use clap::Args; -use safechain_proxy_lib::{server, utils}; +use safechain_proxy_lib::{ + firewall::malware_list::MALWARE_LIST_URI_STR_NPM, server, storage, utils, +}; -use crate::config::{Scenario, ServerConfig}; +use crate::config::{Scenario, ServerConfig, download_malware_list_for_uri}; #[derive(Debug, Clone, Args)] /// run bench mock server @@ -80,7 +82,9 @@ pub async fn exec( AddRequiredResponseHeadersLayer::new() .with_server_header_value(HeaderValue::from_static(utils::env::server_identifier())), ) - .into_layer(Arc::new(MockHttpServer::try_new(merged_cfg).await?)); + .into_layer(Arc::new( + MockHttpServer::try_new(data.clone(), merged_cfg).await?, + )); let http_server = HttpServer::auto(exec).service(Arc::new(http_svc)); @@ -135,7 +139,7 @@ struct MockHttpServer { } impl MockHttpServer { - async fn try_new(cfg: ServerConfig) -> Result { + async fn try_new(data: PathBuf, cfg: ServerConfig) -> Result { let base_latency = cfg.base_latency.unwrap_or_default(); let jitter = cfg.jitter.unwrap_or_default(); let error_rate = cfg.error_rate.unwrap_or_default(); @@ -149,10 +153,19 @@ impl MockHttpServer { )); } + let data_storage = + storage::SyncCompactDataStorage::try_new(data.clone()).with_context(|| { + format!( + "create compact data storage using dir at path '{}'", + data.display() + ) + })?; + tracing::info!(path = ?data, "data directory ready to be used"); + tracing::info!("generating random OK payloads..."); let mut ok_payloads: Vec<&'static [u8]> = Vec::with_capacity(8); - for multiplier in 0..6 { + for multiplier in 0..5 { let payload = InfiniteReader::new() .with_size_limit(2usize.pow(multiplier as u32) * 512) .into_body() @@ -165,6 +178,15 @@ impl MockHttpServer { // compressible payloads ok_payloads.push(include_bytes!("./mod.rs")); ok_payloads.push(include_bytes!("../../../Cargo.toml")); + // very compressible but big payload + ok_payloads.push( + format!( + "{:?}", + download_malware_list_for_uri(data_storage, MALWARE_LIST_URI_STR_NPM).await? + ) + .into_bytes() + .leak(), + ); Ok(Self { base_latency, diff --git a/proxy_netbench/src/config/mod.rs b/proxy_netbench/src/config/mod.rs index 564633dd..aec647e6 100644 --- a/proxy_netbench/src/config/mod.rs +++ b/proxy_netbench/src/config/mod.rs @@ -5,7 +5,9 @@ mod server; pub use self::{ client::ClientConfig, - product::{Product, ProductValues, parse_product_values, rand_requests}, + product::{ + Product, ProductValues, download_malware_list_for_uri, parse_product_values, rand_requests, + }, scenario::Scenario, server::ServerConfig, }; diff --git a/proxy_netbench/src/config/product.rs b/proxy_netbench/src/config/product.rs index c8d5d1a6..601a18fc 100644 --- a/proxy_netbench/src/config/product.rs +++ b/proxy_netbench/src/config/product.rs @@ -33,7 +33,10 @@ use rand::{ rng, seq::IndexedRandom, }; -use safechain_proxy_lib::{firewall::malware_list, storage}; +use safechain_proxy_lib::{ + firewall::malware_list::{self, MALWARE_LIST_URI_STR_PYPI, MALWARE_LIST_URI_STR_VSCODE}, + storage, +}; use tokio::sync::Mutex; rama::utils::macros::enums::enum_builder! { @@ -109,18 +112,12 @@ async fn generate_random_uri( let fresh_entries = match product { Product::None | Product::Unknown(_) => vec![], Product::VSCode => { - download_malware_list_for_uri( - sync_storage.clone(), - "https://malware-list.aikido.dev/malware_vscode.json", - ) - .await? + download_malware_list_for_uri(sync_storage.clone(), MALWARE_LIST_URI_STR_VSCODE) + .await? } Product::PyPI => { - download_malware_list_for_uri( - sync_storage.clone(), - "https://malware-list.aikido.dev/malware_pypi.json", - ) - .await? + download_malware_list_for_uri(sync_storage.clone(), MALWARE_LIST_URI_STR_PYPI) + .await? } }; vacant_entry.insert(fresh_entries) @@ -133,7 +130,7 @@ async fn generate_random_uri( "http://example.com", "https://example.com", "https://aikido.dev", - "https://malware-list.aikido.dev/malware_pypi.json", + MALWARE_LIST_URI_STR_PYPI, "https://http-test.ramaproxy.org/method", "https://http-test.ramaproxy.org/response-stream", "https://http-test.ramaproxy.org/response-compression", @@ -226,7 +223,7 @@ async fn generate_random_uri( } } -async fn download_malware_list_for_uri( +pub async fn download_malware_list_for_uri( sync_storage: storage::SyncCompactDataStorage, uri: &'static str, ) -> Result, OpaqueError> { From 62d46eb31b65b081bbd6a7bb2bc9247c4c83f1f4 Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 24 Jan 2026 14:46:09 +0100 Subject: [PATCH 18/52] improve mock payload + related headers --- proxy_netbench/src/cmd/mock/mod.rs | 31 ++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/proxy_netbench/src/cmd/mock/mod.rs b/proxy_netbench/src/cmd/mock/mod.rs index 09a190b4..b505d7ce 100644 --- a/proxy_netbench/src/cmd/mock/mod.rs +++ b/proxy_netbench/src/cmd/mock/mod.rs @@ -8,13 +8,13 @@ use rama::{ http::{ Body, HeaderName, HeaderValue, InfiniteReader, Request, Response, StatusCode, Uri, body::util::BodyExt, - headers::ContentType, + headers::{ContentLength, ContentType, HeaderMapExt}, layer::{ compression::CompressionLayer, required_header::AddRequiredResponseHeadersLayer, trace::TraceLayer, }, server::HttpServer, - service::web::response::{Headers, IntoResponse}, + service::web::response::IntoResponse, }, layer::{AbortableLayer, TimeoutLayer, abort::AbortController}, net::{ @@ -241,27 +241,42 @@ impl MockHttpServer { std::time::Duration::from_secs_f64(secs) } - fn random_ok_body(&self, uri: &Uri) -> (usize, Body) { + fn random_ok_payload(&self, uri: &Uri) -> (usize, &'static [u8]) { let mut h = std::collections::hash_map::DefaultHasher::new(); std::hash::Hash::hash(&uri, &mut h); let index = (std::hash::Hasher::finish(&h) as usize) % self.ok_payloads.len(); - (index, Body::from(self.ok_payloads[index])) + (index, self.ok_payloads[index]) } fn random_ok_response(&self, req: &Request) -> Response { - let (index, body) = self.random_ok_body(req.uri()); + let (index, payload) = self.random_ok_payload(req.uri()); let index_str = index.to_smolstr(); - ( + + let body = if payload.is_empty() { + Body::empty() + } else { + Body::from(payload) + }; + + let mut resp = ( StatusCode::OK, - Headers::single(ContentType::octet_stream()), [( HeaderName::from_static("x-mock-response-random"), HeaderValue::from_str(&index_str).expect("ascii number to be valid header"), )], body, ) - .into_response() + .into_response(); + if !payload.is_empty() { + resp.headers_mut().typed_insert(ContentType::octet_stream()); + if rand::random_bool(0.5) { + resp.headers_mut() + .typed_insert(ContentLength(payload.len() as u64)); + } + } + + resp } fn error_response() -> Response { From 92c849f72ce1372c61f8a17d1a70ad86e685499c Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 24 Jan 2026 21:18:50 +0100 Subject: [PATCH 19/52] support HAR replay in mock server --- Cargo.lock | 67 ++++----- Cargo.toml | 2 +- proxy_netbench/Cargo.toml | 1 + proxy_netbench/src/cmd/mock/mod.rs | 219 ++++++++++++++++++---------- proxy_netbench/src/cmd/proxy/mod.rs | 10 +- proxy_netbench/src/cmd/run/mod.rs | 2 + proxy_netbench/src/http/har.rs | 139 ++++++++++++++++++ proxy_netbench/src/http/mod.rs | 73 ++++++++++ proxy_netbench/src/main.rs | 1 + 9 files changed, 398 insertions(+), 116 deletions(-) create mode 100644 proxy_netbench/src/http/har.rs create mode 100644 proxy_netbench/src/http/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 97a8ed0d..7d4553a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1586,6 +1586,7 @@ dependencies = [ "rama", "rand", "safechain-proxy-lib", + "serde_json", "tikv-jemallocator", "tokio", ] @@ -1639,9 +1640,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -2090,7 +2091,7 @@ dependencies = [ [[package]] name = "rama" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "ahash", "base64", @@ -2165,7 +2166,7 @@ dependencies = [ [[package]] name = "rama-core" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "ahash", "asynk-strim", @@ -2188,7 +2189,7 @@ dependencies = [ [[package]] name = "rama-crypto" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "aws-lc-rs", "base64", @@ -2204,7 +2205,7 @@ dependencies = [ [[package]] name = "rama-dns" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "ahash", "hickory-resolver", @@ -2218,12 +2219,12 @@ dependencies = [ [[package]] name = "rama-error" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" [[package]] name = "rama-grpc" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "base64", "flate2", @@ -2246,7 +2247,7 @@ dependencies = [ [[package]] name = "rama-grpc-build" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "prettyplease", "proc-macro-crate", @@ -2262,7 +2263,7 @@ dependencies = [ [[package]] name = "rama-haproxy" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "rama-core", "rama-net", @@ -2273,7 +2274,7 @@ dependencies = [ [[package]] name = "rama-http" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "ahash", "async-compression", @@ -2311,7 +2312,7 @@ dependencies = [ [[package]] name = "rama-http-backend" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "pin-project-lite", "rama-core", @@ -2329,7 +2330,7 @@ dependencies = [ [[package]] name = "rama-http-core" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "ahash", "atomic-waker", @@ -2354,7 +2355,7 @@ dependencies = [ [[package]] name = "rama-http-headers" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "ahash", "base64", @@ -2375,7 +2376,7 @@ dependencies = [ [[package]] name = "rama-http-types" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "ahash", "bytes", @@ -2404,7 +2405,7 @@ dependencies = [ [[package]] name = "rama-macros" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2415,7 +2416,7 @@ dependencies = [ [[package]] name = "rama-net" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "ahash", "const_format", @@ -2444,7 +2445,7 @@ dependencies = [ [[package]] name = "rama-proxy" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "arc-swap", "base64", @@ -2460,7 +2461,7 @@ dependencies = [ [[package]] name = "rama-socks5" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "byteorder", "rama-core", @@ -2476,7 +2477,7 @@ dependencies = [ [[package]] name = "rama-tcp" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "pin-project-lite", "rama-core", @@ -2491,7 +2492,7 @@ dependencies = [ [[package]] name = "rama-tls-boring" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "ahash", "brotli", @@ -2516,7 +2517,7 @@ dependencies = [ [[package]] name = "rama-tls-rustls" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "pin-project-lite", "rama-core", @@ -2536,7 +2537,7 @@ dependencies = [ [[package]] name = "rama-ua" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "ahash", "itertools 0.14.0", @@ -2553,7 +2554,7 @@ dependencies = [ [[package]] name = "rama-udp" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "rama-core", "rama-net", @@ -2564,7 +2565,7 @@ dependencies = [ [[package]] name = "rama-unix" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "pin-project-lite", "rama-core", @@ -2575,7 +2576,7 @@ dependencies = [ [[package]] name = "rama-utils" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "const_format", "parking_lot", @@ -2592,7 +2593,7 @@ dependencies = [ [[package]] name = "rama-ws" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=84e9e7dca3a019cf996ed3d63d020b300210bfc2#84e9e7dca3a019cf996ed3d63d020b300210bfc2" +source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" dependencies = [ "flate2", "rama-core", @@ -3216,9 +3217,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.45" +version = "0.3.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" dependencies = [ "deranged", "itoa", @@ -3231,15 +3232,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.25" +version = "0.2.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" dependencies = [ "num-conv", "time-core", diff --git a/Cargo.toml b/Cargo.toml index bb416d44..b67a21a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,4 +35,4 @@ windows-native-keyring-store = "0.5" [workspace.dependencies.rama] git = "https://github.com/plabayo/rama" -rev = "84e9e7dca3a019cf996ed3d63d020b300210bfc2" +rev = "f5c28725341086a8ad2e1d2d4cecb9608c53b644" diff --git a/proxy_netbench/Cargo.toml b/proxy_netbench/Cargo.toml index 764872be..feddbfdb 100644 --- a/proxy_netbench/Cargo.toml +++ b/proxy_netbench/Cargo.toml @@ -12,6 +12,7 @@ resolver = "3" clap = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["full"] } rand = { workspace = true } +serde_json = { workspace = true } safechain-proxy-lib = { workspace = true, features = ["har", "bench"] } [target.'cfg(target_family = "unix")'.dependencies] diff --git a/proxy_netbench/src/cmd/mock/mod.rs b/proxy_netbench/src/cmd/mock/mod.rs index b505d7ce..89af5e8c 100644 --- a/proxy_netbench/src/cmd/mock/mod.rs +++ b/proxy_netbench/src/cmd/mock/mod.rs @@ -6,7 +6,7 @@ use rama::{ extensions::ExtensionsRef, graceful::ShutdownGuard, http::{ - Body, HeaderName, HeaderValue, InfiniteReader, Request, Response, StatusCode, Uri, + Body, HeaderValue, InfiniteReader, Request, Response, StatusCode, Uri, body::util::BodyExt, headers::{ContentLength, ContentType, HeaderMapExt}, layer::{ @@ -28,7 +28,6 @@ use rama::{ tcp::server::TcpListener, telemetry::tracing, tls::boring::server::{TlsAcceptorData, TlsAcceptorLayer}, - utils::str::smol_str::ToSmolStr, }; use clap::Args; @@ -36,7 +35,13 @@ use safechain_proxy_lib::{ firewall::malware_list::MALWARE_LIST_URI_STR_NPM, server, storage, utils, }; -use crate::config::{Scenario, ServerConfig, download_malware_list_for_uri}; +use crate::{ + config::{Scenario, ServerConfig, download_malware_list_for_uri}, + http::{ + MockReplayIndex, MockResponseRandomIndex, + har::{self, HarEntry}, + }, +}; #[derive(Debug, Clone, Args)] /// run bench mock server @@ -57,6 +62,10 @@ pub struct MockCommand { default_value = "127.0.0.1:0" )] pub bind: Interface, + + /// replay the responses from the provided HAR file + #[arg(long, value_name = "HAR_FILE_PATH")] + pub replay: Option, } pub async fn exec( @@ -74,7 +83,7 @@ pub async fn exec( .map_err(OpaqueError::from_boxed) .context("bind proxy meta http(s) server")?; - let merged_cfg = merge_server_cfg(args); + let merged_cfg = merge_server_cfg(args.clone()); let http_svc = ( TraceLayer::new_for_http(), @@ -83,7 +92,7 @@ pub async fn exec( .with_server_header_value(HeaderValue::from_static(utils::env::server_identifier())), ) .into_layer(Arc::new( - MockHttpServer::try_new(data.clone(), merged_cfg).await?, + MockHttpServer::try_new(data.clone(), args.replay.clone(), merged_cfg).await?, )); let http_server = HttpServer::auto(exec).service(Arc::new(http_svc)); @@ -135,11 +144,15 @@ struct MockHttpServer { error_rate: f32, drop_rate: f32, timeout_rate: f32, - ok_payloads: Vec<&'static [u8]>, + ok_responses: OkResponses, } impl MockHttpServer { - async fn try_new(data: PathBuf, cfg: ServerConfig) -> Result { + async fn try_new( + data: PathBuf, + replay: Option, + cfg: ServerConfig, + ) -> Result { let base_latency = cfg.base_latency.unwrap_or_default(); let jitter = cfg.jitter.unwrap_or_default(); let error_rate = cfg.error_rate.unwrap_or_default(); @@ -153,40 +166,20 @@ impl MockHttpServer { )); } - let data_storage = - storage::SyncCompactDataStorage::try_new(data.clone()).with_context(|| { - format!( - "create compact data storage using dir at path '{}'", - data.display() - ) - })?; - tracing::info!(path = ?data, "data directory ready to be used"); - - tracing::info!("generating random OK payloads..."); - - let mut ok_payloads: Vec<&'static [u8]> = Vec::with_capacity(8); - for multiplier in 0..5 { - let payload = InfiniteReader::new() - .with_size_limit(2usize.pow(multiplier as u32) * 512) - .into_body() - .collect() - .await - .context("read generated random body")? - .to_bytes(); - ok_payloads.push(payload.to_vec().leak()); - } - // compressible payloads - ok_payloads.push(include_bytes!("./mod.rs")); - ok_payloads.push(include_bytes!("../../../Cargo.toml")); - // very compressible but big payload - ok_payloads.push( - format!( - "{:?}", - download_malware_list_for_uri(data_storage, MALWARE_LIST_URI_STR_NPM).await? - ) - .into_bytes() - .leak(), - ); + let ok_responses = match replay { + Some(path) => OkResponses::try_new_replay(path).await?, + None => { + let data_storage = storage::SyncCompactDataStorage::try_new(data.clone()) + .with_context(|| { + format!( + "create compact data storage using dir at path '{}'", + data.display() + ) + })?; + tracing::info!(path = ?data, "data directory ready to be used"); + OkResponses::try_new_random_payloads(data_storage).await? + } + }; Ok(Self { base_latency, @@ -194,7 +187,7 @@ impl MockHttpServer { error_rate, drop_rate, timeout_rate, - ok_payloads, + ok_responses, }) } @@ -241,44 +234,6 @@ impl MockHttpServer { std::time::Duration::from_secs_f64(secs) } - fn random_ok_payload(&self, uri: &Uri) -> (usize, &'static [u8]) { - let mut h = std::collections::hash_map::DefaultHasher::new(); - std::hash::Hash::hash(&uri, &mut h); - let index = (std::hash::Hasher::finish(&h) as usize) % self.ok_payloads.len(); - - (index, self.ok_payloads[index]) - } - - fn random_ok_response(&self, req: &Request) -> Response { - let (index, payload) = self.random_ok_payload(req.uri()); - let index_str = index.to_smolstr(); - - let body = if payload.is_empty() { - Body::empty() - } else { - Body::from(payload) - }; - - let mut resp = ( - StatusCode::OK, - [( - HeaderName::from_static("x-mock-response-random"), - HeaderValue::from_str(&index_str).expect("ascii number to be valid header"), - )], - body, - ) - .into_response(); - if !payload.is_empty() { - resp.headers_mut().typed_insert(ContentType::octet_stream()); - if rand::random_bool(0.5) { - resp.headers_mut() - .typed_insert(ContentLength(payload.len() as u64)); - } - } - - resp - } - fn error_response() -> Response { StatusCode::INTERNAL_SERVER_ERROR.into_response() } @@ -312,7 +267,16 @@ impl Service for MockHttpServer { } MockOutcome::Timeout => StatusCode::REQUEST_TIMEOUT.into_response(), MockOutcome::Error => Self::error_response(), - MockOutcome::Ok => self.random_ok_response(&req), + MockOutcome::Ok => match self.ok_responses.generate_response(&req) { + Some(resp) => resp, + None => { + if let Some(controller) = req.extensions().get::() { + controller.abort().await; + } + tracing::error!("failed to abort connection via controller"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + }, }) } } @@ -361,3 +325,96 @@ fn merge_server_cfg(args: MockCommand) -> ServerConfig { } ) } + +#[derive(Debug)] +enum OkResponses { + Random(Vec<&'static [u8]>), + Replay(Vec), +} + +impl OkResponses { + async fn try_new_random_payloads( + data_storage: storage::SyncCompactDataStorage, + ) -> Result { + tracing::info!("generating random OK payloads..."); + + let mut ok_payloads: Vec<&'static [u8]> = Vec::with_capacity(8); + for multiplier in 0..5 { + let payload = InfiniteReader::new() + .with_size_limit(2usize.pow(multiplier as u32) * 512) + .into_body() + .collect() + .await + .context("read generated random body")? + .to_bytes(); + ok_payloads.push(payload.to_vec().leak()); + } + // compressible payloads + ok_payloads.push(include_bytes!("./mod.rs")); + ok_payloads.push(include_bytes!("../../../Cargo.toml")); + // very compressible but big payload + ok_payloads.push( + format!( + "{:?}", + download_malware_list_for_uri(data_storage, MALWARE_LIST_URI_STR_NPM).await? + ) + .into_bytes() + .leak(), + ); + + Ok(Self::Random(ok_payloads)) + } + + async fn try_new_replay(path: PathBuf) -> Result { + tracing::info!("generating replay responses..."); + let entries = har::load_har_entries(path).await?; + Ok(Self::Replay(entries)) + } +} + +fn random_index_from_uri(uri: &Uri, m: usize) -> usize { + let mut h = std::collections::hash_map::DefaultHasher::new(); + std::hash::Hash::hash(&uri, &mut h); + (std::hash::Hasher::finish(&h) as usize) % m +} + +impl OkResponses { + pub fn generate_response(&self, req: &Request) -> Option { + match self { + OkResponses::Random(items) => { + let index = random_index_from_uri(req.uri(), items.len()); + let payload = items[index]; + + let body = if payload.is_empty() { + Body::empty() + } else { + Body::from(payload) + }; + + let mut resp = (StatusCode::OK, body).into_response(); + resp.headers_mut() + .typed_insert(MockResponseRandomIndex(index)); + if !payload.is_empty() { + resp.headers_mut().typed_insert(ContentType::octet_stream()); + if rand::random_bool(0.5) { + resp.headers_mut() + .typed_insert(ContentLength(payload.len() as u64)); + } + } + + Some(resp) + } + OkResponses::Replay(items) => { + let index = match req.headers().typed_get() { + Some(MockReplayIndex(index)) => index % items.len(), + None => random_index_from_uri(req.uri(), items.len()), + }; + items[index].response.as_ref().map(|resp| { + let mut resp = resp.clone_as_http_response(); + resp.headers_mut().typed_insert(MockReplayIndex(index)); + resp + }) + } + } + } +} diff --git a/proxy_netbench/src/cmd/proxy/mod.rs b/proxy_netbench/src/cmd/proxy/mod.rs index 219071c9..9c299d6f 100644 --- a/proxy_netbench/src/cmd/proxy/mod.rs +++ b/proxy_netbench/src/cmd/proxy/mod.rs @@ -108,5 +108,13 @@ pub async fn exec( let proxy_addr = proxy_server.socket_address(); server::write_server_socket_address_as_file(&data, "proxy", proxy_addr).await?; - proxy_server.serve().await + let result = proxy_server.serve().await; + + if args.record_har + && let Err(err) = har_client.toggle().await + { + tracing::error!("failed to toggle HAR recording off again: {err}"); + } + + result } diff --git a/proxy_netbench/src/cmd/run/mod.rs b/proxy_netbench/src/cmd/run/mod.rs index 27a93ec2..2b9f8d6e 100644 --- a/proxy_netbench/src/cmd/run/mod.rs +++ b/proxy_netbench/src/cmd/run/mod.rs @@ -11,6 +11,8 @@ use safechain_proxy_lib::storage; use crate::config::{ClientConfig, ProductValues, Scenario, parse_product_values, rand_requests}; +// TODO: also create client here that we will use... which includes har recording.. + #[derive(Debug, Clone, Args)] /// run benhmarker pub struct RunCommand { diff --git a/proxy_netbench/src/http/har.rs b/proxy_netbench/src/http/har.rs new file mode 100644 index 00000000..c2780f35 --- /dev/null +++ b/proxy_netbench/src/http/har.rs @@ -0,0 +1,139 @@ +use std::{fs::File, path::PathBuf}; + +use rama::{ + bytes::Bytes, + error::{ErrorContext as _, OpaqueError}, + http::{Body, Request, Response, body::util::BodyExt, layer::har, request, response}, +}; + +#[derive(Debug)] +pub struct HarEntry { + pub request: HarRequest, + pub response: Option, + pub start_offset: u64, +} + +#[derive(Debug)] +pub struct HarRequest { + pub parts: request::Parts, + pub payload: Option, +} + +impl HarRequest { + pub fn clone_as_http_request(&self) -> Request { + Request::from_parts( + self.parts.clone(), + match self.payload.as_ref() { + Some(bytes) => Body::from(bytes.clone()), + None => Body::empty(), + }, + ) + } +} + +#[derive(Debug)] +pub struct HarResponse { + pub parts: response::Parts, + pub payload: Option, +} + +impl HarResponse { + pub fn clone_as_http_response(&self) -> Response { + Response::from_parts( + self.parts.clone(), + match self.payload.as_ref() { + Some(bytes) => Body::from(bytes.clone()), + None => Body::empty(), + }, + ) + } +} + +pub async fn load_har_entries(path: PathBuf) -> Result, OpaqueError> { + let log_file: har::spec::LogFile = tokio::task::spawn_blocking(move || { + let file = File::open(path).context("open har file")?; + serde_json::from_reader(file).context("json decode har (log) file") + }) + .await + .context("await blocking json decode task")? + .context("read and decode har file")?; + har_log_file_as_har_entry_vec(log_file).await +} + +async fn har_log_file_as_har_entry_vec( + log_file: har::spec::LogFile, +) -> Result, OpaqueError> { + if log_file.log.entries.is_empty() { + return Err(OpaqueError::from_display( + "empty har log file (contains no entries)", + )); + } + + let mut har_entries = Vec::with_capacity(log_file.log.entries.len()); + let min_start_date = log_file + .log + .entries + .iter() + .map(|entry| entry.started_date_time.timestamp_micros()) + .min() + .context("get min start date for all entries in har log")? + .max(0); + + for entry in log_file.log.entries { + let (parts, body) = Request::try_from(entry.request) + .context("convert har request to http request")? + .into_parts(); + let payload = body.collect().await.context("collect req bod")?.to_bytes(); + let har_request = HarRequest { + parts, + payload: payload.is_empty().then_some(payload), + }; + + let har_response = match entry.response { + Some(response) => { + let (parts, body) = Response::try_from(response) + .context("convert har response to http response")? + .into_parts(); + let payload = body.collect().await.context("collect req bod")?.to_bytes(); + Some(HarResponse { + parts, + payload: payload.is_empty().then_some(payload), + }) + } + None => None, + }; + + har_entries.push(HarEntry { + request: har_request, + response: har_response, + start_offset: (entry.started_date_time.timestamp_micros() - min_start_date) as u64, + }) + } + + har_entries.sort_by_key(|entry| entry.start_offset); + + Ok(har_entries) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_load_har_entries() { + let log_file = serde_json::from_str(HAR_LOG_FILE_EXAMPLE).unwrap(); + let har_entries = har_log_file_as_har_entry_vec(log_file).await.unwrap(); + + assert_eq!(6, har_entries.len()); + + assert_eq!(0, har_entries[0].start_offset); + assert_eq!(320000, har_entries.last().unwrap().start_offset); + + assert_eq!( + "http://www.igvita.com/", + har_entries[0].request.parts.uri.to_string() + ); + } + + const HAR_LOG_FILE_EXAMPLE: &str = r##"{"log":{"version":"1.2","creator":{"name":"WebInspector","version":"537.1"},"pages":[{"startedDateTime":"2012-08-28T05:14:24.803Z","id":"page_1","title":"http://www.igvita.com/","pageTimings":{"onContentLoad":299,"onLoad":301}}],"entries":[{"startedDateTime":"2012-08-28T05:14:24.803Z","time":121,"request":{"method":"GET","url":"http://www.igvita.com/","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"www.igvita.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},{"name":"Cache-Control","value":"max-age=0"}],"queryString":[],"cookies":[],"headersSize":678,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Tue, 28 Aug 2012 05:14:24 GMT"},{"name":"Via","value":"HTTP/1.1 GWA"},{"name":"Transfer-Encoding","value":"chunked"},{"name":"Content-Encoding","value":"gzip"},{"name":"X-XSS-Protection","value":"1; mode=block"},{"name":"X-UA-Compatible","value":"IE=Edge,chrome=1"},{"name":"X-Page-Speed","value":"50_1_cn"},{"name":"Server","value":"nginx/1.0.11"},{"name":"Vary","value":"Accept-Encoding"},{"name":"Content-Type","value":"text/html; charset=utf-8"},{"name":"Cache-Control","value":"max-age=0, no-cache"},{"name":"Expires","value":"Tue, 28 Aug 2012 05:14:24 GMT"}],"cookies":[],"content":{"size":9521,"mimeType":"text/html","compression":5896},"redirectURL":"","headersSize":379,"bodySize":3625},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":1,"wait":112,"receive":6,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.011Z","time":10,"request":{"method":"GET","url":"http://fonts.googleapis.com/css?family=Open+Sans:400,600","httpVersion":"HTTP/1.1","headers":[],"queryString":[{"name":"family","value":"Open+Sans:400,600"}],"cookies":[],"headersSize":71,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[],"cookies":[],"content":{"size":542,"mimeType":"text/css"},"redirectURL":"","headersSize":17,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":-1,"wait":-1,"receive":2,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.017Z","time":31,"request":{"method":"GET","url":"http://1-ps.googleusercontent.com/h/www.igvita.com/css/style.css.pagespeed.ce.LzjUDNB25e.css","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"If-Modified-Since","value":"Mon, 27 Aug 2012 15:28:34 GMT"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"1-ps.googleusercontent.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"text/css,*/*;q=0.1"},{"name":"Cache-Control","value":"max-age=0"},{"name":"If-None-Match","value":"W/0"},{"name":"Referer","value":"http://www.igvita.com/"}],"queryString":[],"cookies":[],"headersSize":539,"bodySize":0},"response":{"status":304,"statusText":"Not Modified","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Mon, 27 Aug 2012 06:01:49 GMT"},{"name":"Age","value":"83556"},{"name":"Server","value":"GFE/2.0"},{"name":"ETag","value":"W/0"},{"name":"Expires","value":"Tue, 27 Aug 2013 06:01:49 GMT"}],"cookies":[],"content":{"size":14679,"mimeType":"text/css"},"redirectURL":"","headersSize":146,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":1,"wait":24,"receive":2,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.021Z","time":30,"request":{"method":"GET","url":"http://1-ps.googleusercontent.com/h/www.igvita.com/js/libs/modernizr.84728.js.pagespeed.jm._DgXLhVY42.js","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"If-Modified-Since","value":"Sat, 25 Aug 2012 14:30:37 GMT"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"1-ps.googleusercontent.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"*/*"},{"name":"Cache-Control","value":"max-age=0"},{"name":"If-None-Match","value":"W/0"},{"name":"Referer","value":"http://www.igvita.com/"}],"queryString":[],"cookies":[],"headersSize":536,"bodySize":0},"response":{"status":304,"statusText":"Not Modified","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Sat, 25 Aug 2012 14:30:37 GMT"},{"name":"Age","value":"225828"},{"name":"Server","value":"GFE/2.0"},{"name":"ETag","value":"W/0"},{"name":"Expires","value":"Sun, 25 Aug 2013 14:30:37 GMT"}],"cookies":[],"content":{"size":11831,"mimeType":"text/javascript"},"redirectURL":"","headersSize":147,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":0,"send":1,"wait":27,"receive":1,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.103Z","time":0,"request":{"method":"GET","url":"http://www.google-analytics.com/ga.js","httpVersion":"HTTP/1.1","headers":[],"queryString":[],"cookies":[],"headersSize":52,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Mon, 27 Aug 2012 21:57:00 GMT"},{"name":"Content-Encoding","value":"gzip"},{"name":"X-Content-Type-Options","value":"nosniff, nosniff"},{"name":"Age","value":"23052"},{"name":"Last-Modified","value":"Thu, 16 Aug 2012 07:05:05 GMT"},{"name":"Server","value":"GFE/2.0"},{"name":"Vary","value":"Accept-Encoding"},{"name":"Content-Type","value":"text/javascript"},{"name":"Expires","value":"Tue, 28 Aug 2012 09:57:00 GMT"},{"name":"Cache-Control","value":"max-age=43200, public"},{"name":"Content-Length","value":"14804"}],"cookies":[],"content":{"size":36893,"mimeType":"text/javascript"},"redirectURL":"","headersSize":17,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":-1,"wait":-1,"receive":0,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.123Z","time":91,"request":{"method":"GET","url":"http://1-ps.googleusercontent.com/beacon?org=50_1_cn&ets=load:93&ifr=0&hft=32&url=http%3A%2F%2Fwww.igvita.com%2F","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"1-ps.googleusercontent.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"*/*"},{"name":"Referer","value":"http://www.igvita.com/"}],"queryString":[{"name":"org","value":"50_1_cn"},{"name":"ets","value":"load:93"},{"name":"ifr","value":"0"},{"name":"hft","value":"32"},{"name":"url","value":"http%3A%2F%2Fwww.igvita.com%2F"}],"cookies":[],"headersSize":448,"bodySize":0},"response":{"status":204,"statusText":"No Content","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Tue, 28 Aug 2012 05:14:25 GMT"},{"name":"Content-Length","value":"0"},{"name":"X-XSS-Protection","value":"1; mode=block"},{"name":"Server","value":"PagespeedRewriteProxy 0.1"},{"name":"Content-Type","value":"text/plain"},{"name":"Cache-Control","value":"no-cache"}],"cookies":[],"content":{"size":0,"mimeType":"text/plain","compression":0},"redirectURL":"","headersSize":202,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":0,"wait":70,"receive":7,"ssl":-1},"pageref":"page_1"}]}}"##; +} diff --git a/proxy_netbench/src/http/mod.rs b/proxy_netbench/src/http/mod.rs new file mode 100644 index 00000000..dbbb5326 --- /dev/null +++ b/proxy_netbench/src/http/mod.rs @@ -0,0 +1,73 @@ +use rama::{ + error::ErrorContext as _, + http::{ + HeaderName, HeaderValue, + headers::{self, HeaderDecode, HeaderEncode, TypedHeader}, + }, + telemetry::tracing, + utils::str::smol_str::ToSmolStr, +}; + +pub mod har; + +macro_rules! impl_typed_usize_header { + ($t:ident, $name:literal) => { + #[derive(Debug, Clone)] + pub struct $t(pub usize); + + impl TypedHeader for $t { + fn name() -> &'static HeaderName { + static NAME: HeaderName = HeaderName::from_static($name); + &NAME + } + } + + impl HeaderEncode for $t { + fn encode>(&self, values: &mut E) { + let s = self.0.to_smolstr(); + match HeaderValue::from_str(&s) { + Ok(v) => values.extend([v]), + Err(err) => { + tracing::error!( + "failed to encode usize '{}' as header value: {err}", + self.0 + ) + } + } + } + } + + impl HeaderDecode for $t { + fn decode<'i, I>(values: &mut I) -> Result + where + Self: Sized, + I: Iterator, + { + let Some(value) = values.next() else { + tracing::trace!("{}: values missing", stringify!($t)); + return Err(headers::Error::invalid()); + }; + + match std::str::from_utf8(value.as_bytes()) + .context("interpret bytes as utf-8 str") + .and_then(|s| s.parse().context("parse string as usize")) + { + Ok(n) => { + if values.next().is_some() { + tracing::trace!("{}: only a single value is expected", stringify!($t)); + return Err(headers::Error::invalid()); + } + Ok(Self(n)) + } + Err(err) => { + tracing::trace!("{}: invalid header value: {err}", stringify!($t)); + return Err(headers::Error::invalid()); + } + } + } + } + }; +} + +impl_typed_usize_header!(MockResponseRandomIndex, "x-mock-response-random-idx"); +impl_typed_usize_header!(MockReplayIndex, "x-mock-replay-idx"); diff --git a/proxy_netbench/src/main.rs b/proxy_netbench/src/main.rs index f7c855bd..bd79b2c1 100644 --- a/proxy_netbench/src/main.rs +++ b/proxy_netbench/src/main.rs @@ -11,6 +11,7 @@ use safechain_proxy_lib::utils; pub mod cmd; pub mod config; +pub mod http; #[cfg(target_family = "unix")] #[global_allocator] From ca27cb30da632d76b40e5cf904bed78a8960161a Mon Sep 17 00:00:00 2001 From: glendc Date: Sun, 25 Jan 2026 20:12:38 +0100 Subject: [PATCH 20/52] update rama deps --- Cargo.lock | 58 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d4553a1..323f6ff4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2050,9 +2050,9 @@ checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" [[package]] name = "psl" -version = "2.1.181" +version = "2.1.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cb4b274dc5b9cd50aa8a5629d57be38bc8098cbecc042d3f911c6cd96b0fc89" +checksum = "f4356be797244137c1d87eb77ec9e389b914e8847607b697d63c578955ce67f0" dependencies = [ "psl-types", ] @@ -2091,7 +2091,7 @@ dependencies = [ [[package]] name = "rama" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "ahash", "base64", @@ -2166,7 +2166,7 @@ dependencies = [ [[package]] name = "rama-core" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "ahash", "asynk-strim", @@ -2189,7 +2189,7 @@ dependencies = [ [[package]] name = "rama-crypto" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "aws-lc-rs", "base64", @@ -2205,7 +2205,7 @@ dependencies = [ [[package]] name = "rama-dns" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "ahash", "hickory-resolver", @@ -2219,12 +2219,12 @@ dependencies = [ [[package]] name = "rama-error" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" [[package]] name = "rama-grpc" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "base64", "flate2", @@ -2247,7 +2247,7 @@ dependencies = [ [[package]] name = "rama-grpc-build" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "prettyplease", "proc-macro-crate", @@ -2263,7 +2263,7 @@ dependencies = [ [[package]] name = "rama-haproxy" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "rama-core", "rama-net", @@ -2274,7 +2274,7 @@ dependencies = [ [[package]] name = "rama-http" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "ahash", "async-compression", @@ -2312,7 +2312,7 @@ dependencies = [ [[package]] name = "rama-http-backend" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "pin-project-lite", "rama-core", @@ -2330,7 +2330,7 @@ dependencies = [ [[package]] name = "rama-http-core" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "ahash", "atomic-waker", @@ -2355,7 +2355,7 @@ dependencies = [ [[package]] name = "rama-http-headers" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "ahash", "base64", @@ -2376,7 +2376,7 @@ dependencies = [ [[package]] name = "rama-http-types" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "ahash", "bytes", @@ -2405,7 +2405,7 @@ dependencies = [ [[package]] name = "rama-macros" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2416,7 +2416,7 @@ dependencies = [ [[package]] name = "rama-net" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "ahash", "const_format", @@ -2445,7 +2445,7 @@ dependencies = [ [[package]] name = "rama-proxy" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "arc-swap", "base64", @@ -2461,7 +2461,7 @@ dependencies = [ [[package]] name = "rama-socks5" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "byteorder", "rama-core", @@ -2477,7 +2477,7 @@ dependencies = [ [[package]] name = "rama-tcp" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "pin-project-lite", "rama-core", @@ -2492,7 +2492,7 @@ dependencies = [ [[package]] name = "rama-tls-boring" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "ahash", "brotli", @@ -2517,7 +2517,7 @@ dependencies = [ [[package]] name = "rama-tls-rustls" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "pin-project-lite", "rama-core", @@ -2537,7 +2537,7 @@ dependencies = [ [[package]] name = "rama-ua" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "ahash", "itertools 0.14.0", @@ -2554,7 +2554,7 @@ dependencies = [ [[package]] name = "rama-udp" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "rama-core", "rama-net", @@ -2565,7 +2565,7 @@ dependencies = [ [[package]] name = "rama-unix" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "pin-project-lite", "rama-core", @@ -2576,7 +2576,7 @@ dependencies = [ [[package]] name = "rama-utils" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "const_format", "parking_lot", @@ -2593,7 +2593,7 @@ dependencies = [ [[package]] name = "rama-ws" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=f5c28725341086a8ad2e1d2d4cecb9608c53b644#f5c28725341086a8ad2e1d2d4cecb9608c53b644" +source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" dependencies = [ "flate2", "rama-core", @@ -3611,9 +3611,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "getrandom 0.3.4", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index b67a21a1..c5d189b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,4 +35,4 @@ windows-native-keyring-store = "0.5" [workspace.dependencies.rama] git = "https://github.com/plabayo/rama" -rev = "f5c28725341086a8ad2e1d2d4cecb9608c53b644" +rev = "ead53c35134d92798c7d46208a8c1156224ecafe" From e5c082c284ad60744443846b82fe0354987c2cbc Mon Sep 17 00:00:00 2001 From: glendc Date: Sun, 25 Jan 2026 23:06:25 +0100 Subject: [PATCH 21/52] bump rama --- Cargo.lock | 50 +++++++++++++++++++++++++------------------------- Cargo.toml | 2 +- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 323f6ff4..1df80cdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2091,7 +2091,7 @@ dependencies = [ [[package]] name = "rama" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "ahash", "base64", @@ -2166,7 +2166,7 @@ dependencies = [ [[package]] name = "rama-core" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "ahash", "asynk-strim", @@ -2189,7 +2189,7 @@ dependencies = [ [[package]] name = "rama-crypto" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "aws-lc-rs", "base64", @@ -2205,7 +2205,7 @@ dependencies = [ [[package]] name = "rama-dns" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "ahash", "hickory-resolver", @@ -2219,12 +2219,12 @@ dependencies = [ [[package]] name = "rama-error" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" [[package]] name = "rama-grpc" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "base64", "flate2", @@ -2247,7 +2247,7 @@ dependencies = [ [[package]] name = "rama-grpc-build" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "prettyplease", "proc-macro-crate", @@ -2263,7 +2263,7 @@ dependencies = [ [[package]] name = "rama-haproxy" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "rama-core", "rama-net", @@ -2274,7 +2274,7 @@ dependencies = [ [[package]] name = "rama-http" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "ahash", "async-compression", @@ -2312,7 +2312,7 @@ dependencies = [ [[package]] name = "rama-http-backend" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "pin-project-lite", "rama-core", @@ -2330,7 +2330,7 @@ dependencies = [ [[package]] name = "rama-http-core" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "ahash", "atomic-waker", @@ -2355,7 +2355,7 @@ dependencies = [ [[package]] name = "rama-http-headers" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "ahash", "base64", @@ -2376,7 +2376,7 @@ dependencies = [ [[package]] name = "rama-http-types" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "ahash", "bytes", @@ -2405,7 +2405,7 @@ dependencies = [ [[package]] name = "rama-macros" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2416,7 +2416,7 @@ dependencies = [ [[package]] name = "rama-net" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "ahash", "const_format", @@ -2445,7 +2445,7 @@ dependencies = [ [[package]] name = "rama-proxy" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "arc-swap", "base64", @@ -2461,7 +2461,7 @@ dependencies = [ [[package]] name = "rama-socks5" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "byteorder", "rama-core", @@ -2477,7 +2477,7 @@ dependencies = [ [[package]] name = "rama-tcp" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "pin-project-lite", "rama-core", @@ -2492,7 +2492,7 @@ dependencies = [ [[package]] name = "rama-tls-boring" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "ahash", "brotli", @@ -2517,7 +2517,7 @@ dependencies = [ [[package]] name = "rama-tls-rustls" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "pin-project-lite", "rama-core", @@ -2537,7 +2537,7 @@ dependencies = [ [[package]] name = "rama-ua" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "ahash", "itertools 0.14.0", @@ -2554,7 +2554,7 @@ dependencies = [ [[package]] name = "rama-udp" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "rama-core", "rama-net", @@ -2565,7 +2565,7 @@ dependencies = [ [[package]] name = "rama-unix" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "pin-project-lite", "rama-core", @@ -2576,7 +2576,7 @@ dependencies = [ [[package]] name = "rama-utils" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "const_format", "parking_lot", @@ -2593,7 +2593,7 @@ dependencies = [ [[package]] name = "rama-ws" version = "0.3.0-rc1" -source = "git+https://github.com/plabayo/rama?rev=ead53c35134d92798c7d46208a8c1156224ecafe#ead53c35134d92798c7d46208a8c1156224ecafe" +source = "git+https://github.com/plabayo/rama?rev=eb977a57abde52c9e64c5977aea6dc943863c3d8#eb977a57abde52c9e64c5977aea6dc943863c3d8" dependencies = [ "flate2", "rama-core", diff --git a/Cargo.toml b/Cargo.toml index c5d189b6..3c727358 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,4 +35,4 @@ windows-native-keyring-store = "0.5" [workspace.dependencies.rama] git = "https://github.com/plabayo/rama" -rev = "ead53c35134d92798c7d46208a8c1156224ecafe" +rev = "eb977a57abde52c9e64c5977aea6dc943863c3d8" From cd97eebd3b3efbb718e6287dffda3906542a1f9b Mon Sep 17 00:00:00 2001 From: glendc Date: Mon, 26 Jan 2026 01:08:29 +0100 Subject: [PATCH 22/52] prepare runner code to be able to also use replay --- proxy_netbench/src/cmd/mock/mod.rs | 3 +- proxy_netbench/src/cmd/run/mod.rs | 34 ++- .../src/cmd/run/requests/mock/malware.rs | 66 +++++ .../src/cmd/run/requests/mock/mod.rs | 96 ++++++++ .../src/cmd/run/requests/mock/none.rs | 24 ++ .../src/cmd/run/requests/mock/pypi.rs | 68 +++++ .../src/cmd/run/requests/mock/vscode.rs | 86 +++++++ proxy_netbench/src/cmd/run/requests/mod.rs | 2 + proxy_netbench/src/cmd/run/requests/replay.rs | 1 + proxy_netbench/src/config/mod.rs | 4 +- proxy_netbench/src/config/product.rs | 232 +----------------- 11 files changed, 365 insertions(+), 251 deletions(-) create mode 100644 proxy_netbench/src/cmd/run/requests/mock/malware.rs create mode 100644 proxy_netbench/src/cmd/run/requests/mock/mod.rs create mode 100644 proxy_netbench/src/cmd/run/requests/mock/none.rs create mode 100644 proxy_netbench/src/cmd/run/requests/mock/pypi.rs create mode 100644 proxy_netbench/src/cmd/run/requests/mock/vscode.rs create mode 100644 proxy_netbench/src/cmd/run/requests/mod.rs create mode 100644 proxy_netbench/src/cmd/run/requests/replay.rs diff --git a/proxy_netbench/src/cmd/mock/mod.rs b/proxy_netbench/src/cmd/mock/mod.rs index 89af5e8c..124c2d8f 100644 --- a/proxy_netbench/src/cmd/mock/mod.rs +++ b/proxy_netbench/src/cmd/mock/mod.rs @@ -36,7 +36,8 @@ use safechain_proxy_lib::{ }; use crate::{ - config::{Scenario, ServerConfig, download_malware_list_for_uri}, + cmd::run::requests::mock::malware::download_malware_list_for_uri, + config::{Scenario, ServerConfig}, http::{ MockReplayIndex, MockResponseRandomIndex, har::{self, HarEntry}, diff --git a/proxy_netbench/src/cmd/run/mod.rs b/proxy_netbench/src/cmd/run/mod.rs index 2b9f8d6e..4dcbcc22 100644 --- a/proxy_netbench/src/cmd/run/mod.rs +++ b/proxy_netbench/src/cmd/run/mod.rs @@ -9,7 +9,9 @@ use clap::Args; use safechain_proxy_lib::storage; -use crate::config::{ClientConfig, ProductValues, Scenario, parse_product_values, rand_requests}; +use crate::config::{ClientConfig, ProductValues, Scenario, parse_product_values}; + +pub mod requests; // TODO: also create client here that we will use... which includes har recording.. @@ -34,8 +36,16 @@ pub struct RunCommand { #[arg(long, value_parser = parse_product_values)] /// Scenario to run, /// manually defined parameters overwrite scenario parameters. + /// + /// Not used when replaying. products: Option, + /// How much mock product requests generations should contain malaware. + /// + /// Not used when replaying. + #[arg(long, value_name = "SECONDS", default_value_t = 0.1)] + malware_ratio: f64, + #[arg(long)] /// Scenario to run, /// manually defined parameters overwrite scenario parameters. @@ -62,25 +72,13 @@ pub async fn exec(data: PathBuf, args: RunCommand) -> Result<(), OpaqueError> { let request_count_per_warmup = (args.warmup * target_rps as f64).next_up() as usize; let iterations = args.iterations.max(1); - let mut requests_per_iteration = Vec::with_capacity(iterations); - for i in 0..iterations { - tracing::info!( - "generate #{request_count_per_iteration} random requests for iteration {i} / {iterations}" - ); - let requests = rand_requests( - &data_storage, - request_count_per_iteration, - args.products.clone(), - ) - .await?; - requests_per_iteration.push(requests); - } - - tracing::info!("generate #{request_count_per_warmup} random requests for warmup"); - let _requests = rand_requests( - &data_storage, + let (_requests_for_iterations, _requests_for_warmup) = self::requests::mock::rand_requests( + data_storage, + iterations, + request_count_per_iteration, request_count_per_warmup, args.products.clone(), + args.malware_ratio, ) .await?; diff --git a/proxy_netbench/src/cmd/run/requests/mock/malware.rs b/proxy_netbench/src/cmd/run/requests/mock/malware.rs new file mode 100644 index 00000000..d1d93ae0 --- /dev/null +++ b/proxy_netbench/src/cmd/run/requests/mock/malware.rs @@ -0,0 +1,66 @@ +use std::{sync::LazyLock, time::Duration}; + +use rama::{ + Layer as _, Service as _, + error::OpaqueError, + http::{ + Body, Request, Response, Uri, + client::EasyHttpWebClient, + layer::{ + decompression::DecompressionLayer, + map_request_body::MapRequestBodyLayer, + map_response_body::MapResponseBodyLayer, + retry::{ManagedPolicy, RetryLayer}, + timeout::TimeoutLayer, + }, + }, + layer::MapErrLayer, + service::BoxService, + utils::{backoff::ExponentialBackoff, rng::HasherRng}, +}; + +use safechain_proxy_lib::{ + firewall::malware_list::{self}, + storage, +}; + +pub async fn download_malware_list_for_uri( + sync_storage: storage::SyncCompactDataStorage, + uri: &'static str, +) -> Result, OpaqueError> { + let client = shared_download_client(); + malware_list::RemoteMalwareList::download_data_entry_list( + Uri::from_static(uri), + sync_storage, + client, + ) + .await +} + +fn shared_download_client() -> BoxService { + static CLIENT: LazyLock> = LazyLock::new(|| { + let inner_https_client = EasyHttpWebClient::default(); + ( + MapResponseBodyLayer::new(Body::new), + DecompressionLayer::new(), + MapErrLayer::new(OpaqueError::from_std), + TimeoutLayer::new(Duration::from_secs(60)), + RetryLayer::new( + ManagedPolicy::default().with_backoff( + ExponentialBackoff::new( + Duration::from_millis(100), + Duration::from_secs(30), + 0.01, + HasherRng::default, + ) + .expect("create exponential backoff impl"), + ), + ), + MapRequestBodyLayer::new(Body::new), + ) + .into_layer(inner_https_client) + .boxed() + }); + + CLIENT.clone() +} diff --git a/proxy_netbench/src/cmd/run/requests/mock/mod.rs b/proxy_netbench/src/cmd/run/requests/mock/mod.rs new file mode 100644 index 00000000..eb709d83 --- /dev/null +++ b/proxy_netbench/src/cmd/run/requests/mock/mod.rs @@ -0,0 +1,96 @@ +use rama::{ + error::OpaqueError, + http::{Body, Request}, + telemetry::tracing, +}; +use rand::distr::{Distribution as _, weighted::WeightedIndex}; +use safechain_proxy_lib::storage; + +use crate::config::{Product, ProductValues, default_product_values}; + +mod none; +mod pypi; +mod vscode; + +pub mod malware; + +/// Generate N random requests for a M iterations + warmup +pub async fn rand_requests( + sync_storage: storage::SyncCompactDataStorage, + iterations: usize, + request_count: usize, + request_count_warmup: usize, + products: Option, + malware_ratio: f64, +) -> Result<(Vec>, Vec), OpaqueError> { + let products = products.unwrap_or_else(default_product_values); + tracing::info!( + "using products: {}", + products + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + ); + + let mut vscode = self::vscode::VSCodeUriGenerator::new(sync_storage.clone()); + let mut pypi = self::pypi::PyPIUriGenerator::new(sync_storage); + + let mut total_requests = Vec::with_capacity(iterations); + for i in 1..=iterations { + tracing::info!( + "generate #{request_count} random requests for iteration {i} / {iterations}" + ); + total_requests.push( + rand_requests_inner( + request_count, + &products, + malware_ratio, + &mut vscode, + &mut pypi, + ) + .await?, + ); + } + + tracing::info!("generate #{request_count_warmup} random requests for warmup"); + let requests_warmup = rand_requests_inner( + request_count_warmup, + &products, + malware_ratio, + &mut vscode, + &mut pypi, + ) + .await?; + + Ok((total_requests, requests_warmup)) +} + +/// Generate N random requests for a single iteration +async fn rand_requests_inner( + request_count: usize, + products: &ProductValues, + malware_ratio: f64, + vscode: &mut self::vscode::VSCodeUriGenerator, + pypi: &mut self::pypi::PyPIUriGenerator, +) -> Result, OpaqueError> { + let mut requests = Vec::with_capacity(request_count); + + let weights: Vec<_> = products.iter().map(|p| p.quality.as_u16()).collect(); + let dist = WeightedIndex::new(&weights).unwrap(); + for _ in 0..request_count { + let product = products[dist.sample(&mut rand::rng())].value.clone(); + + let uri = match product { + Product::None | Product::Unknown(_) => self::none::random_uri()?, + Product::VSCode => vscode.random_uri(malware_ratio).await?, + Product::PyPI => pypi.random_uri(malware_ratio).await?, + }; + + let mut req = Request::new(Body::empty()); + *req.uri_mut() = uri; + requests.push(req); + } + + Ok(requests) +} diff --git a/proxy_netbench/src/cmd/run/requests/mock/none.rs b/proxy_netbench/src/cmd/run/requests/mock/none.rs new file mode 100644 index 00000000..6d8d7f15 --- /dev/null +++ b/proxy_netbench/src/cmd/run/requests/mock/none.rs @@ -0,0 +1,24 @@ +use rama::{ + error::{ErrorContext as _, OpaqueError}, + http::Uri, +}; + +use rand::{rng, seq::IndexedRandom as _}; + +use safechain_proxy_lib::firewall::malware_list::MALWARE_LIST_URI_STR_PYPI; + +pub(super) fn random_uri() -> Result { + Ok(Uri::from_static( + [ + "http://example.com", + "https://example.com", + "https://aikido.dev", + MALWARE_LIST_URI_STR_PYPI, + "https://http-test.ramaproxy.org/method", + "https://http-test.ramaproxy.org/response-stream", + "https://http-test.ramaproxy.org/response-compression", + ] + .choose(&mut rng()) + .context("select random None uri")?, + )) +} diff --git a/proxy_netbench/src/cmd/run/requests/mock/pypi.rs b/proxy_netbench/src/cmd/run/requests/mock/pypi.rs new file mode 100644 index 00000000..d5c39178 --- /dev/null +++ b/proxy_netbench/src/cmd/run/requests/mock/pypi.rs @@ -0,0 +1,68 @@ +use rama::{ + error::{ErrorContext as _, OpaqueError}, + http::Uri, +}; + +use rand::{rng, seq::IndexedRandom as _}; + +use safechain_proxy_lib::{ + firewall::malware_list::{self, MALWARE_LIST_URI_STR_PYPI}, + storage::SyncCompactDataStorage, +}; + +use super::malware::download_malware_list_for_uri; + +#[derive(Debug)] +pub(super) struct PyPIUriGenerator { + storage: Option, + malware_list: Vec, +} + +impl PyPIUriGenerator { + pub(super) fn new(storage: SyncCompactDataStorage) -> Self { + Self { + storage: Some(storage), + malware_list: Default::default(), + } + } + + pub(super) async fn random_uri(&mut self, malware_ratio: f64) -> Result { + if let Some(storage) = self.storage.take() { + self.malware_list = download_malware_list_for_uri(storage, MALWARE_LIST_URI_STR_PYPI) + .await + .context("download pypi malware_list")?; + } + + const URI_TEMPLATES: &[&str] = &[ + "https://pypi.org/pypi//json", + "https://pypi.org/simple//", + "https://files.pythonhosted.org/packages/abc/def/--py3-none-any.whl", + "https://files.pythonhosted.org/packages/source/d//-.tar.gz", + "https://pypi.org/pypi//json", + "https://pypi.org/", + "https://pypi.org/help/", + ]; + + let template = URI_TEMPLATES + .choose(&mut rng()) + .context("select random PyPI uri template")?; + + if rand::random_bool(malware_ratio) { + let entry = self + .malware_list + .choose(&mut rng()) + .context("select random PyPI malware")?; + template + .replace("", &entry.package_name) + .replace("", &entry.version.to_string()) + .parse() + .context("parse PyPI uri") + } else { + template + .replace("", "netbench-foo") + .replace("", "bar") + .parse() + .context("parse PyPI uri") + } + } +} diff --git a/proxy_netbench/src/cmd/run/requests/mock/vscode.rs b/proxy_netbench/src/cmd/run/requests/mock/vscode.rs new file mode 100644 index 00000000..17745c2f --- /dev/null +++ b/proxy_netbench/src/cmd/run/requests/mock/vscode.rs @@ -0,0 +1,86 @@ +use rama::{ + error::{ErrorContext as _, OpaqueError}, + http::Uri, +}; + +use rand::{rng, seq::IndexedRandom as _}; + +use safechain_proxy_lib::{ + firewall::malware_list::{self, MALWARE_LIST_URI_STR_VSCODE}, + storage::SyncCompactDataStorage, +}; + +use super::malware::download_malware_list_for_uri; + +#[derive(Debug)] +pub(super) struct VSCodeUriGenerator { + storage: Option, + malware_list: Vec, +} + +impl VSCodeUriGenerator { + pub(super) fn new(storage: SyncCompactDataStorage) -> Self { + Self { + storage: Some(storage), + malware_list: Default::default(), + } + } + + pub(super) async fn random_uri(&mut self, malware_ratio: f64) -> Result { + if let Some(storage) = self.storage.take() { + self.malware_list = download_malware_list_for_uri(storage, MALWARE_LIST_URI_STR_VSCODE) + .await + .context("download vscode malware_list")?; + } + + const DOMAINS: &[&str] = &[ + "gallery.vsassets.io", + "gallerycdn.vsassets.io", + "marketplace.visualstudio.com", + "netbench-foo.gallery.vsassets.io", + "netbench-foo.gallerycdn.vsassets.io", + ]; + const PATH_TEMPLATES: &[&str] = &[ + "/files////foo", + "/extensions///foo", + "/_apis/public/gallery/publishers//vsextensions//foo", + "/_apis/public/gallery/publisher///foo", + "/_apis/public/gallery/publisher//extension//foo", + ]; + + let domain = DOMAINS + .choose(&mut rng()) + .context("select random pypi domain")?; + let path_template = PATH_TEMPLATES + .choose(&mut rng()) + .context("select random pypi path template")?; + + // TODO: make this configurable via cli arg + + if rand::random_bool(malware_ratio) { + let entry = self + .malware_list + .choose(&mut rng()) + .context("select random pypi malware")?; + let (publisher, extension) = entry + .package_name + .split_once(".") + .unwrap_or(("aikido", entry.package_name.as_str())); + let path = path_template + .replace("", publisher) + .replace("", extension) + .replace("", &entry.version.to_string()); + format!("https://{domain}{path}") + .parse() + .context("parse pypi uri") + } else { + let path = path_template + .replace("", "aikido") + .replace("", "netbench-foo") + .replace("", "foo"); + format!("https://{domain}{path}") + .parse() + .context("parse pypi uri") + } + } +} diff --git a/proxy_netbench/src/cmd/run/requests/mod.rs b/proxy_netbench/src/cmd/run/requests/mod.rs new file mode 100644 index 00000000..2a6332c2 --- /dev/null +++ b/proxy_netbench/src/cmd/run/requests/mod.rs @@ -0,0 +1,2 @@ +pub mod mock; +pub mod replay; diff --git a/proxy_netbench/src/cmd/run/requests/replay.rs b/proxy_netbench/src/cmd/run/requests/replay.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/proxy_netbench/src/cmd/run/requests/replay.rs @@ -0,0 +1 @@ + diff --git a/proxy_netbench/src/config/mod.rs b/proxy_netbench/src/config/mod.rs index aec647e6..5f72a073 100644 --- a/proxy_netbench/src/config/mod.rs +++ b/proxy_netbench/src/config/mod.rs @@ -5,9 +5,7 @@ mod server; pub use self::{ client::ClientConfig, - product::{ - Product, ProductValues, download_malware_list_for_uri, parse_product_values, rand_requests, - }, + product::{Product, ProductValues, default_product_values, parse_product_values}, scenario::Scenario, server::ServerConfig, }; diff --git a/proxy_netbench/src/config/product.rs b/proxy_netbench/src/config/product.rs index 601a18fc..ed909b5d 100644 --- a/proxy_netbench/src/config/product.rs +++ b/proxy_netbench/src/config/product.rs @@ -1,43 +1,7 @@ -use std::{ - collections::{HashMap, hash_map::Entry}, - sync::LazyLock, - time::Duration, -}; - use rama::{ - Layer as _, Service as _, - error::{ErrorContext as _, OpaqueError}, - http::{ - Body, Request, Response, Uri, - client::EasyHttpWebClient, - headers::specifier::{Quality, QualityValue}, - layer::{ - decompression::DecompressionLayer, - map_request_body::MapRequestBodyLayer, - map_response_body::MapResponseBodyLayer, - retry::{ManagedPolicy, RetryLayer}, - timeout::TimeoutLayer, - }, - }, - layer::MapErrLayer, - service::BoxService, - utils::{ - backoff::ExponentialBackoff, - collections::{NonEmptyVec, non_empty_vec}, - rng::HasherRng, - }, -}; - -use rand::{ - distr::{Distribution as _, weighted::WeightedIndex}, - rng, - seq::IndexedRandom, -}; -use safechain_proxy_lib::{ - firewall::malware_list::{self, MALWARE_LIST_URI_STR_PYPI, MALWARE_LIST_URI_STR_VSCODE}, - storage, + http::headers::specifier::{Quality, QualityValue}, + utils::collections::{NonEmptyVec, non_empty_vec}, }; -use tokio::sync::Mutex; rama::utils::macros::enums::enum_builder! { /// Some of the products we support and which to support @@ -53,30 +17,6 @@ rama::utils::macros::enums::enum_builder! { } } -/// Generate N random requests for the given product ratio -pub async fn rand_requests( - sync_storage: &storage::SyncCompactDataStorage, - request_count: usize, - products: Option, -) -> Result, OpaqueError> { - let products = products.unwrap_or_else(default_product_values); - - let mut requests = Vec::with_capacity(request_count); - - let weights: Vec<_> = products.iter().map(|p| p.quality.as_u16()).collect(); - let dist = WeightedIndex::new(&weights).unwrap(); - for _ in 0..request_count { - let product = products[dist.sample(&mut rand::rng())].value.clone(); - let uri = generate_random_uri(sync_storage, product).await?; - - let mut req = Request::new(Body::empty()); - *req.uri_mut() = uri; - requests.push(req); - } - - Ok(requests) -} - pub fn parse_product_values(input: &str) -> Result { let result: Result>, _> = input.split(",").map(|s| s.parse()).collect(); @@ -90,7 +30,7 @@ pub fn parse_product_values(input: &str) -> Result { pub type ProductValues = NonEmptyVec>; /// Default [`ProductValues`] used in case none are defined in cli args. -fn default_product_values() -> ProductValues { +pub fn default_product_values() -> ProductValues { non_empty_vec![ QualityValue::new(Product::None, Quality::one()), QualityValue::new(Product::VSCode, Quality::new_clamped(100)), @@ -98,172 +38,6 @@ fn default_product_values() -> ProductValues { ] } -async fn generate_random_uri( - sync_storage: &storage::SyncCompactDataStorage, - product: Product, -) -> Result { - static LISTS: LazyLock>>> = - LazyLock::new(Default::default); - let mut lists = LISTS.lock().await; - let list = lists.entry(product.clone()); - let entries = match list { - Entry::Occupied(ref occupied_entry) => occupied_entry.get(), - Entry::Vacant(vacant_entry) => { - let fresh_entries = match product { - Product::None | Product::Unknown(_) => vec![], - Product::VSCode => { - download_malware_list_for_uri(sync_storage.clone(), MALWARE_LIST_URI_STR_VSCODE) - .await? - } - Product::PyPI => { - download_malware_list_for_uri(sync_storage.clone(), MALWARE_LIST_URI_STR_PYPI) - .await? - } - }; - vacant_entry.insert(fresh_entries) - } - }; - - match product { - Product::None | Product::Unknown(_) => Ok(Uri::from_static( - [ - "http://example.com", - "https://example.com", - "https://aikido.dev", - MALWARE_LIST_URI_STR_PYPI, - "https://http-test.ramaproxy.org/method", - "https://http-test.ramaproxy.org/response-stream", - "https://http-test.ramaproxy.org/response-compression", - ] - .choose(&mut rng()) - .context("select random None uri")?, - )), - Product::VSCode => { - const DOMAINS: &[&str] = &[ - "gallery.vsassets.io", - "gallerycdn.vsassets.io", - "marketplace.visualstudio.com", - "netbench-foo.gallery.vsassets.io", - "netbench-foo.gallerycdn.vsassets.io", - ]; - const PATH_TEMPLATES: &[&str] = &[ - "/files////foo", - "/extensions///foo", - "/_apis/public/gallery/publishers//vsextensions//foo", - "/_apis/public/gallery/publisher///foo", - "/_apis/public/gallery/publisher//extension//foo", - ]; - - let domain = DOMAINS - .choose(&mut rng()) - .context("select random pypi domain")?; - let path_template = PATH_TEMPLATES - .choose(&mut rng()) - .context("select random pypi path template")?; - - // TODO: make this configurable via cli arg - - if rand::random_bool(0.1) { - let entry = entries - .choose(&mut rng()) - .context("select random pypi malware")?; - let (publisher, extension) = entry - .package_name - .split_once(".") - .unwrap_or(("aikido", entry.package_name.as_str())); - let path = path_template - .replace("", publisher) - .replace("", extension) - .replace("", &entry.version.to_string()); - format!("https://{domain}{path}") - .parse() - .context("parse pypi uri") - } else { - let path = path_template - .replace("", "aikido") - .replace("", "netbench-foo") - .replace("", "foo"); - format!("https://{domain}{path}") - .parse() - .context("parse pypi uri") - } - } - Product::PyPI => { - const URI_TEMPLATES: &[&str] = &[ - "https://pypi.org/pypi//json", - "https://pypi.org/simple//", - "https://files.pythonhosted.org/packages/abc/def/--py3-none-any.whl", - "https://files.pythonhosted.org/packages/source/d//-.tar.gz", - "https://pypi.org/pypi//json", - "https://pypi.org/", - "https://pypi.org/help/", - ]; - - let template = URI_TEMPLATES - .choose(&mut rng()) - .context("select random vscode uri template")?; - - if rand::random_bool(0.1) { - let entry = entries - .choose(&mut rng()) - .context("select random vscode malware")?; - template - .replace("", &entry.package_name) - .replace("", &entry.version.to_string()) - .parse() - .context("parse vscode uri") - } else { - template - .replace("", "netbench-foo") - .replace("", "bar") - .parse() - .context("parse vscode uri") - } - } - } -} - -pub async fn download_malware_list_for_uri( - sync_storage: storage::SyncCompactDataStorage, - uri: &'static str, -) -> Result, OpaqueError> { - let client = shared_download_client(); - malware_list::RemoteMalwareList::download_data_entry_list( - Uri::from_static(uri), - sync_storage, - client, - ) - .await -} - -fn shared_download_client() -> BoxService { - static CLIENT: LazyLock> = LazyLock::new(|| { - let inner_https_client = EasyHttpWebClient::default(); - ( - MapResponseBodyLayer::new(Body::new), - DecompressionLayer::new(), - MapErrLayer::new(OpaqueError::from_std), - TimeoutLayer::new(Duration::from_secs(60)), - RetryLayer::new( - ManagedPolicy::default().with_backoff( - ExponentialBackoff::new( - Duration::from_millis(100), - Duration::from_secs(30), - 0.01, - HasherRng::default, - ) - .expect("create exponential backoff impl"), - ), - ), - MapRequestBodyLayer::new(Body::new), - ) - .into_layer(inner_https_client) - .boxed() - }); - - CLIENT.clone() -} - #[cfg(test)] mod tests { use super::*; From d5f06ed20cfb72c36c1e7f7fb3d5f4ef5c49e83e Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 29 Jan 2026 18:09:47 +0100 Subject: [PATCH 23/52] ensure proxy_fuzz makes now use of safechain-proxy-lib --- .github/workflows/checks.yml | 4 ++-- Cargo.lock | 3 +-- Cargo.toml | 3 ++- justfile | 4 ++-- proxy-fuzz/parse_pragmatic_semver_version.rs | 19 ------------------- {proxy-fuzz => proxy_fuzz}/.gitignore | 0 {proxy-fuzz => proxy_fuzz}/Cargo.toml | 5 ++--- proxy_fuzz/parse_pragmatic_semver_version.rs | 10 ++++++++++ 8 files changed, 19 insertions(+), 29 deletions(-) delete mode 100644 proxy-fuzz/parse_pragmatic_semver_version.rs rename {proxy-fuzz => proxy_fuzz}/.gitignore (100%) rename {proxy-fuzz => proxy_fuzz}/Cargo.toml (82%) create mode 100644 proxy_fuzz/parse_pragmatic_semver_version.rs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 0465ccd4..9f06078b 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -109,8 +109,8 @@ jobs: key: cargo-fuzz-bin-${{ env.CARGO_FUZZ_VERSION }} - run: echo "${{ runner.tool_cache }}/cargo-fuzz/bin" >> $GITHUB_PATH - run: cargo install --root "${{ runner.tool_cache }}/cargo-fuzz" --version ${{ env.CARGO_FUZZ_VERSION }} cargo-fuzz --locked - - run: cargo fuzz build --fuzz-dir ./proxy-fuzz ${{ matrix.fuzz_target }} - - run: cargo fuzz run --fuzz-dir ./proxy-fuzz ${{ matrix.fuzz_target }} -- -max_total_time=${{ env.FUZZ_TIME }} + - run: cargo fuzz build --fuzz-dir ./proxy_fuzz ${{ matrix.fuzz_target }} + - run: cargo fuzz run --fuzz-dir ./proxy_fuzz ${{ matrix.fuzz_target }} -- -max_total_time=${{ env.FUZZ_TIME }} - uses: actions/upload-artifact@v4 if: failure() with: diff --git a/Cargo.lock b/Cargo.lock index 157f6f69..e0bc9629 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2908,8 +2908,7 @@ name = "safechain-proxy-fuzz" version = "0.1.0" dependencies = [ "libfuzzer-sys", - "rama", - "serde", + "safechain-proxy-lib", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 97b48701..db22e98d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["proxy", "proxy-fuzz", "proxy_cli", "proxy_netbench"] +members = ["proxy", "proxy_cli", "proxy_fuzz", "proxy_netbench"] resolver = "3" [workspace.package] @@ -15,6 +15,7 @@ humantime = "2.3" jemallocator = { package = "tikv-jemallocator", version = "0.6" } keyring-core = "0.7" libc = "0.2" +libfuzzer-sys = "0.4" linux-keyutils-keyring-store = "0.2" lz4_flex = "0.12" mimalloc = { version = "0.1", default-features = false } diff --git a/justfile b/justfile index 841a27af..cdf4a915 100644 --- a/justfile +++ b/justfile @@ -16,11 +16,11 @@ rust-qa: rust-fuzz-check: @cargo install cargo-fuzz - cargo +nightly fuzz check --fuzz-dir ./proxy-fuzz + cargo +nightly fuzz check --fuzz-dir ./proxy_fuzz rust-fuzz *ARGS: @cargo install cargo-fuzz - cargo +nightly fuzz run --fuzz-dir ./proxy-fuzz -j 8 parse_pragmatic_semver_version -- -max_total_time=60 + cargo +nightly fuzz run --fuzz-dir ./proxy_fuzz -j 8 parse_pragmatic_semver_version -- -max_total_time=60 rust-qa-full: rust-qa rust-fuzz cargo nextest run --workspace --all-features --run-ignored=only diff --git a/proxy-fuzz/parse_pragmatic_semver_version.rs b/proxy-fuzz/parse_pragmatic_semver_version.rs deleted file mode 100644 index 545ade8c..00000000 --- a/proxy-fuzz/parse_pragmatic_semver_version.rs +++ /dev/null @@ -1,19 +0,0 @@ -#![no_main] - -// NOTE: once refactor of netbench code is merged, -// we can use safechain_proxy_lib. In the current main version -// of safechain_proxy however we are still dealing with a binary only, -// so this is for now the easiest way to include that code and be able to test it -mod pragmatic_semver { - #![allow(unused)] - - include!("../proxy/src/firewall/version/pragmatic_semver.rs"); -} -use self::pragmatic_semver::PragmaticSemver; - -libfuzzer_sys::fuzz_target!(|bytes: &[u8]| { - let Ok(s) = std::str::from_utf8(bytes) else { - return; - }; - let _ = PragmaticSemver::parse(s); -}); diff --git a/proxy-fuzz/.gitignore b/proxy_fuzz/.gitignore similarity index 100% rename from proxy-fuzz/.gitignore rename to proxy_fuzz/.gitignore diff --git a/proxy-fuzz/Cargo.toml b/proxy_fuzz/Cargo.toml similarity index 82% rename from proxy-fuzz/Cargo.toml rename to proxy_fuzz/Cargo.toml index fe375dfe..d9a895fb 100644 --- a/proxy-fuzz/Cargo.toml +++ b/proxy_fuzz/Cargo.toml @@ -12,9 +12,8 @@ resolver = "3" cargo-fuzz = true [dependencies] -libfuzzer-sys = "0.4" -rama = { workspace = true } -serde = "1.0" +libfuzzer-sys = { workspace = true } +safechain-proxy-lib = { workspace = true } [[bin]] name = "parse_pragmatic_semver_version" diff --git a/proxy_fuzz/parse_pragmatic_semver_version.rs b/proxy_fuzz/parse_pragmatic_semver_version.rs new file mode 100644 index 00000000..d5189219 --- /dev/null +++ b/proxy_fuzz/parse_pragmatic_semver_version.rs @@ -0,0 +1,10 @@ +#![no_main] + +use ::safechain_proxy_lib::firewall::version::PragmaticSemver; + +libfuzzer_sys::fuzz_target!(|bytes: &[u8]| { + let Ok(s) = std::str::from_utf8(bytes) else { + return; + }; + let _ = PragmaticSemver::parse(s); +}); From 6da0baa8bdd81d42f0c4dad0a025204865963dc4 Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 29 Jan 2026 21:18:12 +0100 Subject: [PATCH 24/52] initial proxy bench runner code --- proxy_netbench/src/cmd/mock/mod.rs | 2 +- proxy_netbench/src/cmd/run/client.rs | 32 + proxy_netbench/src/cmd/run/mod.rs | 184 ++++- proxy_netbench/src/cmd/run/reporter/human.rs | 77 ++ proxy_netbench/src/cmd/run/reporter/json.rs | 98 +++ proxy_netbench/src/cmd/run/reporter/mod.rs | 48 ++ .../src/cmd/run/requests/generator.rs | 354 ++++++++++ proxy_netbench/src/cmd/run/requests/mod.rs | 9 +- .../src/cmd/run/requests/rps_pacer.rs | 186 +++++ .../cmd/run/requests/{ => source}/mock/mod.rs | 12 +- .../run/requests/{ => source}/mock/none.rs | 0 .../run/requests/{ => source}/mock/pypi.rs | 2 +- .../run/requests/{ => source}/mock/vscode.rs | 2 +- .../src/cmd/run/requests/source/mod.rs | 666 ++++++++++++++++++ .../cmd/run/requests/{ => source}/replay.rs | 0 proxy_netbench/src/config/scenario.rs | 6 +- .../run/requests/mock => http}/malware.rs | 0 proxy_netbench/src/http/mod.rs | 1 + proxy_netbench/src/main.rs | 2 +- 19 files changed, 1637 insertions(+), 44 deletions(-) create mode 100644 proxy_netbench/src/cmd/run/client.rs create mode 100644 proxy_netbench/src/cmd/run/reporter/human.rs create mode 100644 proxy_netbench/src/cmd/run/reporter/json.rs create mode 100644 proxy_netbench/src/cmd/run/reporter/mod.rs create mode 100644 proxy_netbench/src/cmd/run/requests/generator.rs create mode 100644 proxy_netbench/src/cmd/run/requests/rps_pacer.rs rename proxy_netbench/src/cmd/run/requests/{ => source}/mock/mod.rs (91%) rename proxy_netbench/src/cmd/run/requests/{ => source}/mock/none.rs (100%) rename proxy_netbench/src/cmd/run/requests/{ => source}/mock/pypi.rs (97%) rename proxy_netbench/src/cmd/run/requests/{ => source}/mock/vscode.rs (98%) create mode 100644 proxy_netbench/src/cmd/run/requests/source/mod.rs rename proxy_netbench/src/cmd/run/requests/{ => source}/replay.rs (100%) rename proxy_netbench/src/{cmd/run/requests/mock => http}/malware.rs (100%) diff --git a/proxy_netbench/src/cmd/mock/mod.rs b/proxy_netbench/src/cmd/mock/mod.rs index 124c2d8f..bc33ce44 100644 --- a/proxy_netbench/src/cmd/mock/mod.rs +++ b/proxy_netbench/src/cmd/mock/mod.rs @@ -36,11 +36,11 @@ use safechain_proxy_lib::{ }; use crate::{ - cmd::run::requests::mock::malware::download_malware_list_for_uri, config::{Scenario, ServerConfig}, http::{ MockReplayIndex, MockResponseRandomIndex, har::{self, HarEntry}, + malware::download_malware_list_for_uri, }, }; diff --git a/proxy_netbench/src/cmd/run/client.rs b/proxy_netbench/src/cmd/run/client.rs new file mode 100644 index 00000000..8666ad4c --- /dev/null +++ b/proxy_netbench/src/cmd/run/client.rs @@ -0,0 +1,32 @@ +use rama::{ + Layer as _, Service as _, + error::OpaqueError, + http::{Request, Response}, + layer::AddInputExtensionLayer, + net::{ + Protocol, + address::{ProxyAddress, SocketAddress}, + }, + rt::Executor, + service::BoxService, +}; +use safechain_proxy_lib::client::{new_web_client, transport::try_set_egress_address_overwrite}; + +pub fn http_cient( + exec: Executor, + target: SocketAddress, + proxy: bool, +) -> Result, OpaqueError> { + if proxy { + Ok(AddInputExtensionLayer::new(ProxyAddress { + protocol: Some(Protocol::HTTP), + address: target.into(), + credential: None, + }) + .into_layer(new_web_client(exec)?) + .boxed()) + } else { + try_set_egress_address_overwrite(target)?; + Ok(new_web_client(exec)?.boxed()) + } +} diff --git a/proxy_netbench/src/cmd/run/mod.rs b/proxy_netbench/src/cmd/run/mod.rs index 4dcbcc22..9c16c575 100644 --- a/proxy_netbench/src/cmd/run/mod.rs +++ b/proxy_netbench/src/cmd/run/mod.rs @@ -1,23 +1,47 @@ -use std::path::PathBuf; +use std::{path::PathBuf, time::Duration}; use rama::{ + Service as _, error::{ErrorContext as _, OpaqueError}, + graceful::ShutdownGuard, + net::address::SocketAddress, + rt::Executor, telemetry::tracing, }; use clap::Args; +use tokio::time::Instant; + +use crate::{ + cmd::run::requests::{ + GeneratedRequest, RequestGenerator, RequestGeneratorMockConfig, + RequestGeneratorReplayConfig, + }, + config::{ClientConfig, ProductValues, Scenario, parse_product_values}, +}; -use safechain_proxy_lib::storage; - -use crate::config::{ClientConfig, ProductValues, Scenario, parse_product_values}; - +pub mod client; +pub mod reporter; pub mod requests; -// TODO: also create client here that we will use... which includes har recording.. +use self::reporter::*; #[derive(Debug, Clone, Args)] /// run benhmarker pub struct RunCommand { + /// socket address of the proxy if proxied + /// or else the address of the target (mock) server if directly connecting. + #[arg(value_name = "ADDRESS", required = true)] + target: SocketAddress, + + /// run via a proxy + #[arg(long = "proxy", default_value_t = false)] + proxy: bool, + + /// report json instead of a human-friendly format + #[arg(long, default_value_t = false)] + json: bool, + #[clap(flatten)] config: Option, @@ -46,43 +70,145 @@ pub struct RunCommand { #[arg(long, value_name = "SECONDS", default_value_t = 0.1)] malware_ratio: f64, + /// Replay the requests from the provided HAR file + #[arg(long, value_name = "HAR_FILE_PATH")] + replay: Option, + + /// When replaying also emulate the given timings + #[arg(long = "emulate", default_value_t = false)] + emulate_timing: bool, + #[arg(long)] /// Scenario to run, /// manually defined parameters overwrite scenario parameters. scenario: Option, } -pub async fn exec(data: PathBuf, args: RunCommand) -> Result<(), OpaqueError> { - tokio::fs::create_dir_all(&data) - .await - .with_context(|| format!("create data directory at path '{}'", data.display()))?; - let data_storage = - storage::SyncCompactDataStorage::try_new(data.clone()).with_context(|| { - format!( - "create compact data storage using dir at path '{}'", - data.display() - ) - })?; - tracing::info!(path = ?data, "data directory ready to be used"); +pub async fn exec( + data: PathBuf, + guard: ShutdownGuard, + args: RunCommand, +) -> Result<(), OpaqueError> { + let client = + self::client::http_cient(Executor::graceful(guard.clone()), args.target, args.proxy) + .context("create HTTP(S) client")?; let merged_cfg = merge_server_cfg(args.scenario, args.config); - let target_rps = merged_cfg.target_rps.unwrap_or(1000); + let target_rps = merged_cfg.target_rps.unwrap_or(200).max(1); + let burst_size = merged_cfg.burst_size.unwrap_or_default().max(1); + let jitter = merged_cfg.jitter.unwrap_or_default().clamp(0.0, 1.0); + let request_count_per_iteration = (args.duration * target_rps as f64).next_up() as usize; let request_count_per_warmup = (args.warmup * target_rps as f64).next_up() as usize; let iterations = args.iterations.max(1); - let (_requests_for_iterations, _requests_for_warmup) = self::requests::mock::rand_requests( - data_storage, - iterations, - request_count_per_iteration, - request_count_per_warmup, - args.products.clone(), - args.malware_ratio, - ) - .await?; - Ok(()) + let mut req_gen = match args.replay { + Some(har_fp) => RequestGenerator::new_replay_gen(RequestGeneratorReplayConfig { + har: har_fp, + iterations, + target_rps, + burst_size, + jitter, + emulate_timing: args.emulate_timing, + }) + .await + .context("create replay req generator")?, + None => RequestGenerator::new_mock_gen(RequestGeneratorMockConfig { + data, + iterations, + target_rps, + burst_size, + jitter, + request_count_per_iteration, + request_count_per_warmup, + products: args.products, + malware_ratio: args.malware_ratio, + }) + .await + .context("create mock req generator")?, + }; + + const REPORT_INTERVAL: Duration = Duration::from_secs(1); + + let mut reporter: Box = if args.json { + const EMIT_EVENTS: bool = true; + Box::new(JsonlReporter::new(REPORT_INTERVAL, EMIT_EVENTS)) + } else { + Box::new(HumanReporter::new(REPORT_INTERVAL)) + }; + + let mut cancelled = std::pin::pin!(guard.downgrade().into_cancelled()); + + let start = Instant::now(); + + loop { + let GeneratedRequest { + req, + index, + iteration, + warmup, + } = tokio::select! { + _ = cancelled.as_mut() => { + tracing::error!("exit bench runner early: guard shutdown"); + return Ok(()); + } + maybe_req = req_gen.next_request() => { + let Some(req) = maybe_req else { + tracing::debug!("bench runner done: exit"); + return Ok(()); + }; + + req + } + }; + + let phase = if warmup { Phase::Warmup } else { Phase::Main }; + + let req_start = Instant::now(); + let outcome = match client.serve(req).await { + Ok(resp) => { + let status = resp.status().as_u16(); + if (200..400).contains(&status) { + RequestOutcome { + ok: true, + status: Some(status), + failure: None, + } + } else { + RequestOutcome { + ok: false, + status: Some(status), + failure: Some(FailureKind::HttpStatus), + } + } + } + Err(err) => { + tracing::debug!("non-http error: {err}"); + RequestOutcome { + ok: false, + status: None, + failure: Some(FailureKind::Other), + } + } + }; + + let ev = RequestResultEvent { + ts: std::time::SystemTime::now(), + elapsed: start.elapsed(), + phase, + iteration, + index, + latency: req_start.elapsed(), + outcome, + }; + + reporter.on_result(&ev); + + let now = start.elapsed(); + reporter.on_tick(now); + } } fn merge_server_cfg(scenario: Option, config: Option) -> ClientConfig { diff --git a/proxy_netbench/src/cmd/run/reporter/human.rs b/proxy_netbench/src/cmd/run/reporter/human.rs new file mode 100644 index 00000000..2559d89c --- /dev/null +++ b/proxy_netbench/src/cmd/run/reporter/human.rs @@ -0,0 +1,77 @@ +use super::{Counters, FailureKind, Phase, Reporter, RequestResultEvent}; + +pub struct HumanReporter { + interval: std::time::Duration, + last_tick: std::time::Duration, + interval_counts: Counters, + total_counts: Counters, + last_pos: Option<(Phase, usize, usize)>, +} + +impl HumanReporter { + pub fn new(interval: std::time::Duration) -> Self { + Self { + interval, + last_tick: std::time::Duration::ZERO, + interval_counts: Counters::default(), + total_counts: Counters::default(), + last_pos: None, + } + } + + pub(super) fn apply_counts(c: &mut Counters, ev: &RequestResultEvent) { + c.total += 1; + if ev.outcome.ok { + c.ok += 1; + return; + } + match ev.outcome.failure { + Some(FailureKind::HttpStatus) => c.http_fail += 1, + _ => c.other_fail += 1, + } + } +} + +impl Reporter for HumanReporter { + fn on_result(&mut self, ev: &RequestResultEvent) { + Self::apply_counts(&mut self.interval_counts, ev); + Self::apply_counts(&mut self.total_counts, ev); + self.last_pos = Some((ev.phase, ev.iteration, ev.index)); + } + + fn on_tick(&mut self, now: std::time::Duration) { + if now.saturating_sub(self.last_tick) < self.interval { + return; + } + self.last_tick = now; + + let rps = self.interval_counts.total as f64 / self.interval.as_secs_f64(); + let (phase, it, idx) = self.last_pos.unwrap_or((Phase::Warmup, 0, 0)); + + println!( + "t={:.1}s phase={:?} it={} idx={} rps={:.1} ok={} http_fail={} other_fail={} total_ok={} total_fail={}", + now.as_secs_f64(), + phase, + it, + idx, + rps, + self.interval_counts.ok, + self.interval_counts.http_fail, + self.interval_counts.other_fail, + self.total_counts.ok, + self.total_counts.total - self.total_counts.ok, + ); + + self.interval_counts = Counters::default(); + } + + fn finish(&mut self) { + println!( + "done ok={} http_fail={} other_fail={} total={}", + self.total_counts.ok, + self.total_counts.http_fail, + self.total_counts.other_fail, + self.total_counts.total, + ); + } +} diff --git a/proxy_netbench/src/cmd/run/reporter/json.rs b/proxy_netbench/src/cmd/run/reporter/json.rs new file mode 100644 index 00000000..df7bff34 --- /dev/null +++ b/proxy_netbench/src/cmd/run/reporter/json.rs @@ -0,0 +1,98 @@ +use super::{Counters, FailureKind, Phase, Reporter, RequestResultEvent, human::HumanReporter}; + +pub struct JsonlReporter { + interval: std::time::Duration, + last_tick: std::time::Duration, + interval_counts: Counters, + total_counts: Counters, + last_pos: Option<(Phase, usize, usize)>, + emit_events: bool, +} + +impl JsonlReporter { + pub fn new(interval: std::time::Duration, emit_events: bool) -> Self { + Self { + interval, + last_tick: std::time::Duration::ZERO, + interval_counts: Counters::default(), + total_counts: Counters::default(), + last_pos: None, + emit_events, + } + } +} + +impl Reporter for JsonlReporter { + fn on_result(&mut self, ev: &RequestResultEvent) { + HumanReporter::apply_counts(&mut self.interval_counts, ev); + HumanReporter::apply_counts(&mut self.total_counts, ev); + self.last_pos = Some((ev.phase, ev.iteration, ev.index)); + + if self.emit_events { + let line = serde_json::json!({ + "type": "event", + "t_ms": ev.elapsed.as_millis(), + "phase": match ev.phase { Phase::Warmup => "warmup", Phase::Main => "main" }, + "iteration": ev.iteration, + "index": ev.index, + "latency_ms": ev.latency.as_millis(), + "ok": ev.outcome.ok, + "status": ev.outcome.status, + "failure": match ev.outcome.failure { + Some(FailureKind::HttpStatus) => Some("http_status"), + Some(FailureKind::Other) => Some("other"), + None => None, + }, + }); + println!("{}", line); + } + } + + fn on_tick(&mut self, now: std::time::Duration) { + if now.saturating_sub(self.last_tick) < self.interval { + return; + } + self.last_tick = now; + + let rps = self.interval_counts.total as f64 / self.interval.as_secs_f64(); + let (phase, it, idx) = self.last_pos.unwrap_or((Phase::Warmup, 0, 0)); + + let line = serde_json::json!({ + "type": "summary", + "t_ms": now.as_millis(), + "phase": match phase { Phase::Warmup => "warmup", Phase::Main => "main" }, + "iteration": it, + "index": idx, + "interval_ms": self.interval.as_millis(), + "rps": rps, + "interval": { + "total": self.interval_counts.total, + "ok": self.interval_counts.ok, + "http_fail": self.interval_counts.http_fail, + "other_fail": self.interval_counts.other_fail, + }, + "total": { + "total": self.total_counts.total, + "ok": self.total_counts.ok, + "http_fail": self.total_counts.http_fail, + "other_fail": self.total_counts.other_fail, + } + }); + println!("{}", line); + + self.interval_counts = Counters::default(); + } + + fn finish(&mut self) { + let line = serde_json::json!({ + "type": "final", + "total": { + "total": self.total_counts.total, + "ok": self.total_counts.ok, + "http_fail": self.total_counts.http_fail, + "other_fail": self.total_counts.other_fail, + } + }); + println!("{}", line); + } +} diff --git a/proxy_netbench/src/cmd/run/reporter/mod.rs b/proxy_netbench/src/cmd/run/reporter/mod.rs new file mode 100644 index 00000000..79254403 --- /dev/null +++ b/proxy_netbench/src/cmd/run/reporter/mod.rs @@ -0,0 +1,48 @@ +mod human; +mod json; + +pub use self::{human::HumanReporter, json::JsonlReporter}; + +pub trait Reporter: Send + Sync + 'static { + fn on_result(&mut self, ev: &RequestResultEvent); + fn on_tick(&mut self, now: std::time::Duration); + fn finish(&mut self); +} + +#[derive(Default)] +pub struct Counters { + total: u64, + ok: u64, + http_fail: u64, + other_fail: u64, +} + +#[derive(Debug, Clone, Copy)] +pub enum Phase { + Warmup, + Main, +} + +#[derive(Debug)] +pub enum FailureKind { + HttpStatus, + Other, +} + +#[derive(Debug)] +pub struct RequestOutcome { + pub ok: bool, + pub status: Option, + pub failure: Option, +} + +#[derive(Debug)] +pub struct RequestResultEvent { + pub ts: std::time::SystemTime, + pub elapsed: std::time::Duration, + pub phase: Phase, + pub iteration: usize, + pub index: usize, + pub latency: std::time::Duration, + pub outcome: RequestOutcome, +} diff --git a/proxy_netbench/src/cmd/run/requests/generator.rs b/proxy_netbench/src/cmd/run/requests/generator.rs new file mode 100644 index 00000000..cba66662 --- /dev/null +++ b/proxy_netbench/src/cmd/run/requests/generator.rs @@ -0,0 +1,354 @@ +use std::path::PathBuf; + +use rama::{ + error::{ErrorContext as _, OpaqueError}, + http::Request, + telemetry::tracing, +}; +use safechain_proxy_lib::storage::{self}; +use tokio::time::{Instant, sleep}; + +use crate::{ + cmd::run::requests::{ + rps_pacer::RpsPacer, + source::{self, Cursor, DelayKind, Source, mock}, + }, + config::ProductValues, +}; + +#[cfg(not(test))] +use crate::http::har; + +/// Generates requests from either a mock source or a replay source. +/// +/// Intent +/// This type coordinates three concerns: +/// 1. Source selection and per source cursor advancement +/// 2. Warmup to main transition +/// 3. Pacing and delay application +/// +/// The source decides what it wants to emit next via `plan_next` and `produce_next`. +/// The generator is responsible for honoring the pacing decision in a consistent way. +pub struct RequestGenerator { + source: Source, + pacer: RpsPacer, + pacing: PacingConfig, + state: GenState, +} + +pub struct RequestGeneratorMockConfig { + pub data: PathBuf, + pub iterations: usize, + pub target_rps: u32, + pub burst_size: u32, + pub jitter: f64, + pub request_count_per_iteration: usize, + pub request_count_per_warmup: usize, + pub products: Option, + pub malware_ratio: f64, +} + +pub struct RequestGeneratorReplayConfig { + pub har: PathBuf, + pub iterations: usize, + pub target_rps: u32, + pub burst_size: u32, + pub jitter: f64, + pub emulate_timing: bool, +} + +impl RequestGenerator { + pub async fn new_mock_gen(cfg: RequestGeneratorMockConfig) -> Result { + tokio::fs::create_dir_all(&cfg.data) + .await + .with_context(|| format!("create data directory at path '{}'", cfg.data.display()))?; + let data_storage = storage::SyncCompactDataStorage::try_new(cfg.data.clone()) + .with_context(|| { + format!( + "create compact data storage using dir at path '{}'", + cfg.data.display() + ) + })?; + tracing::info!(path = ?cfg.data, "data directory ready to be used"); + + let (requests, warmup) = mock::rand_requests( + data_storage, + cfg.iterations, + cfg.request_count_per_iteration, + cfg.request_count_per_warmup, + cfg.products, + cfg.malware_ratio, + ) + .await?; + + let source = Source::Mock(source::MockSource { requests, warmup }); + + Ok(Self::new_internal( + source, + PacingConfig { + target_rps: cfg.target_rps, + burst_size: cfg.burst_size, + jitter: cfg.jitter, + emulate_replay_timing: false, // only possible for replay-based generator + }, + )) + } + + pub async fn new_replay_gen(cfg: RequestGeneratorReplayConfig) -> Result { + #[cfg(not(test))] + let entries = har::load_har_entries(cfg.har) + .await + .context("load replay HAR file")?; + + #[cfg(test)] + let entries = Default::default(); // TODO can be changed if we ever want to test this method + + let source = Source::Replay(source::ReplaySource { + entries, + iterations: cfg.iterations, + warmup_iterations: 1, + emulate_timing: cfg.emulate_timing, + }); + + Ok(Self::new_internal( + source, + PacingConfig { + target_rps: cfg.target_rps, + burst_size: cfg.burst_size, + jitter: cfg.jitter, + emulate_replay_timing: cfg.emulate_timing, // only possible for replay-based generator + }, + )) + } + + fn new_internal(source: Source, pacing: PacingConfig) -> Self { + let pacer = RpsPacer::new(pacing.target_rps, pacing.burst_size, pacing.jitter); + + Self { + source, + pacer, + pacing, + state: GenState::new(), + } + } + + /// Produces the next request and applies the required delay before returning it. + /// + /// Returns `None` when the generator is fully exhausted. + pub async fn next_request(&mut self) -> Option { + self.state.advance_if_needed(&self.source); + + let plan = self.source.plan_next(&self.state)?; + self.apply_delay(plan.delay_kind).await; + + self.source.produce_next(&mut self.state, plan) + } + + async fn apply_delay(&mut self, delay: DelayKind) { + match delay { + DelayKind::Rps => self.pacer.wait_one().await, + DelayKind::ReplayEmulation { start_offset } => { + // Replay emulation is only honored for main traffic and only when enabled. + // Rationale: warmup is intended for stabilization, not timing fidelity. + if !self.state.is_main() || !self.pacing.emulate_replay_timing { + self.pacer.wait_one().await; + return; + } + + let base = self + .state + .replay_base_instant + .get_or_insert_with(Instant::now); + + let target = *base + start_offset; + let now = Instant::now(); + if target > now { + sleep(target - now).await; + } + } + } + } + + #[cfg(test)] + fn state(&self) -> &GenState { + &self.state + } +} + +/// Configuration for pacing. +/// +/// `target_rps` +/// Average requests per second when using the RPS pacer. +/// +/// `burst_size` +/// Maximum number of requests that can be emitted immediately when tokens are available. +/// +/// `jitter` +/// Random multiplier on the computed wait time, in the range `[0.0, 1.0]`. +/// +/// `emulate_replay_timing` +/// When true, and when the source requests replay timing, and when in main phase, +/// the generator sleeps until the recorded offset from the start of the iteration. +#[derive(Clone, Copy, Debug)] +pub(super) struct PacingConfig { + pub(super) target_rps: u32, + pub(super) burst_size: u32, + pub(super) jitter: f64, + pub(super) emulate_replay_timing: bool, +} + +/// Output of the generator. +#[derive(Debug)] +pub struct GeneratedRequest { + pub req: Request, + pub index: usize, + pub iteration: usize, + pub warmup: bool, +} + +/// Tracks whether we are in warmup or main and holds the active cursor. +/// +/// The cursor is source specific, the generator only resets it at the phase boundary. +pub(super) struct GenState { + pub(super) warmup: bool, + pub(super) cursor: Cursor, + + /// Replay emulation uses an absolute schedule relative to a base instant. + /// This is set on the first emitted request of each main iteration. + pub(super) replay_base_instant: Option, +} + +impl GenState { + pub(super) fn new() -> Self { + Self { + warmup: true, + cursor: Cursor::new(), + replay_base_instant: None, + } + } + + pub(super) fn is_warmup(&self) -> bool { + self.warmup + } + + pub(super) fn is_main(&self) -> bool { + !self.warmup + } + + /// Transitions from warmup to main when the source indicates warmup is exhausted. + /// + /// This is intentionally centralized here so the rest of the generator can treat the state + /// as always consistent. + pub(super) fn advance_if_needed(&mut self, source: &Source) { + if self.warmup && source.warmup_is_done(&self.cursor) { + self.warmup = false; + self.cursor.reset_for_main(); + self.replay_base_instant = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rama::http::Body; + use std::{collections::VecDeque, time::Duration}; + use tokio::task::yield_now; + use tokio::time; + + use crate::cmd::run::requests::source::{HarEntry, HarRequest, MockSource, ReplaySource}; + + fn pacing(emulate: bool) -> PacingConfig { + PacingConfig { + target_rps: 1, + burst_size: 10, + jitter: 0.0, + emulate_replay_timing: emulate, + } + } + + fn req() -> Request { + Request::builder() + .uri("http://localhost/") + .body(Body::empty()) + .expect("request") + } + + #[tokio::test(flavor = "current_thread")] + async fn transitions_from_warmup_to_main_and_emits_main_request() { + // Warmup is empty, so the first call should transition to main immediately. + let mock = MockSource { + warmup: VecDeque::new(), + requests: vec![VecDeque::from([req()])], + }; + + let mut generator = RequestGenerator::new_internal(Source::Mock(mock), pacing(false)); + + let out = generator.next_request().await.expect("generated"); + assert!(!out.warmup); + assert_eq!(out.iteration, 0); + assert_eq!(out.index, 0); + assert!(generator.state().is_main()); + } + + #[tokio::test(flavor = "current_thread")] + async fn replay_emulation_blocks_until_offset_when_enabled() { + time::pause(); + + // This assumes HarEntry start_offset is stored in microseconds, as in your source module. + // It also assumes HarEntry and its request can be constructed in tests. + let entry = HarEntry { + start_offset: 1_000_000, + request: HarRequest, + }; + + let replay = ReplaySource { + entries: vec![entry], + iterations: 1, + warmup_iterations: 0, + emulate_timing: true, + }; + + let mut generator = RequestGenerator::new_internal(Source::Replay(replay), pacing(true)); + + // Spawn next_request so we can observe it blocking under paused time. + let h = tokio::spawn(async move { generator.next_request().await }); + + yield_now().await; + assert!(!h.is_finished()); + + // Advance just short of the offset. + time::advance(Duration::from_millis(999)).await; + yield_now().await; + assert!(!h.is_finished()); + + // Advance to reach the offset. + time::advance(Duration::from_millis(1)).await; + let out = h.await.expect("join").expect("generated"); + assert!(!out.warmup); + } + + #[tokio::test(flavor = "current_thread")] + async fn replay_emulation_is_ignored_during_warmup_or_when_disabled() { + time::pause(); + + let entry = HarEntry { + start_offset: 2_000_000, + request: HarRequest, + }; + + let replay = ReplaySource { + entries: vec![entry], + iterations: 1, + warmup_iterations: 0, + emulate_timing: true, + }; + + // Emulation disabled at generator level, so the delay should fall back to RPS. + // We give a large burst so it does not sleep. + let mut generator = RequestGenerator::new_internal(Source::Replay(replay), pacing(false)); + + let out = generator.next_request().await.expect("generated"); + assert!(!out.warmup); + } +} diff --git a/proxy_netbench/src/cmd/run/requests/mod.rs b/proxy_netbench/src/cmd/run/requests/mod.rs index 2a6332c2..fcfecfcc 100644 --- a/proxy_netbench/src/cmd/run/requests/mod.rs +++ b/proxy_netbench/src/cmd/run/requests/mod.rs @@ -1,2 +1,7 @@ -pub mod mock; -pub mod replay; +mod generator; +mod rps_pacer; +mod source; + +pub use self::generator::{ + GeneratedRequest, RequestGenerator, RequestGeneratorMockConfig, RequestGeneratorReplayConfig, +}; diff --git a/proxy_netbench/src/cmd/run/requests/rps_pacer.rs b/proxy_netbench/src/cmd/run/requests/rps_pacer.rs new file mode 100644 index 00000000..846d7e63 --- /dev/null +++ b/proxy_netbench/src/cmd/run/requests/rps_pacer.rs @@ -0,0 +1,186 @@ +use std::time::Duration; + +use rand::{Rng as _, SeedableRng as _}; +use tokio::time::{Instant, sleep}; + +/// Token bucket RPS pacer. +/// +/// The pacer maintains a floating point token count. +/// Tokens refill continuously at `target_rps` per second, capped by `burst_size`. +/// Emitting a request consumes one token. +/// If there is not enough token balance, the pacer sleeps until at least one token is available. +/// +/// Jitter is applied to the computed sleep duration. +/// Token accounting is not jittered so the long term average converges to the configured rate. +pub(super) struct RpsPacer { + target_rps: f64, + capacity: f64, + tokens: f64, + last: Instant, + jitter: f64, + rng: rand::rngs::SmallRng, +} + +impl RpsPacer { + pub(super) fn new(target_rps: u32, burst_size: u32, jitter: f64) -> Self { + Self::new_with_rng( + target_rps, + burst_size, + jitter, + rand::rngs::SmallRng::from_os_rng(), + ) + } + + fn new_with_rng( + target_rps: u32, + burst_size: u32, + jitter: f64, + rng: rand::rngs::SmallRng, + ) -> Self { + let target_rps = target_rps.max(1) as f64; + let capacity = burst_size.max(1) as f64; + + Self { + target_rps, + capacity, + tokens: capacity, + last: Instant::now(), + jitter: jitter.clamp(0.0, 1.0), + rng, + } + } + + pub(super) async fn wait_one(&mut self) { + loop { + self.refill(); + + if self.tokens >= 1.0 { + self.tokens -= 1.0; + return; + } + + let missing = 1.0 - self.tokens; + let base_wait = Duration::from_secs_f64(missing / self.target_rps); + + let wait = self.jittered(base_wait); + + // Guard against sleeping for zero when we still need to wait. + let wait = if wait.is_zero() { + Duration::from_nanos(1) + } else { + wait + }; + + sleep(wait).await; + } + } + + fn refill(&mut self) { + let now = Instant::now(); + let dt = now.duration_since(self.last).as_secs_f64(); + self.last = now; + + self.tokens = (self.tokens + dt * self.target_rps).min(self.capacity); + } + + fn jittered(&mut self, d: Duration) -> Duration { + if self.jitter <= 0.0 { + return d; + } + + let lo = 1.0 - self.jitter; + let hi = 1.0 + self.jitter; + let m = self.rng.random_range(lo..=hi); + + Duration::from_secs_f64((d.as_secs_f64() * m).max(0.0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::{task::yield_now, time}; + + fn seeded_pacer(target_rps: u32, burst_size: u32, jitter: f64, seed: u64) -> RpsPacer { + let rng = rand::rngs::SmallRng::seed_from_u64(seed); + RpsPacer::new_with_rng(target_rps, burst_size, jitter, rng) + } + + #[tokio::test(flavor = "current_thread")] + async fn burst_allows_immediate_tokens() { + time::pause(); + + let mut p = seeded_pacer(10, 3, 0.0, 1); + + // Should not sleep for the initial burst. + p.wait_one().await; + p.wait_one().await; + p.wait_one().await; + + // Next one should block because no time has advanced to refill. + let h = tokio::spawn(async move { + let mut p = p; + p.wait_one().await; + p + }); + + yield_now().await; + assert!(!h.is_finished()); + } + + #[tokio::test(flavor = "current_thread")] + async fn refills_at_target_rps_after_time_advances() { + time::pause(); + + let mut p = seeded_pacer(2, 1, 0.0, 2); + + // Consume the single burst token. + p.wait_one().await; + + // Next wait should need 0.5 seconds at 2 rps. + let h = tokio::spawn(async move { + let mut p = p; + p.wait_one().await; + p + }); + + yield_now().await; + assert!(!h.is_finished()); + + time::advance(Duration::from_millis(499)).await; + yield_now().await; + assert!(!h.is_finished()); + + time::advance(Duration::from_millis(1)).await; + let _p = h.await.expect("task join"); + } + + #[test] + fn jitter_bounds_are_respected() { + let mut p = seeded_pacer(10, 1, 0.25, 3); + let d = Duration::from_secs(10); + + let j = p.jittered(d); + let secs = j.as_secs_f64(); + + // jitter 0.25 means multiplier in [0.75, 1.25] + assert!(secs >= 7.5); + assert!(secs <= 12.5); + } + + #[tokio::test(flavor = "current_thread")] + async fn refill_caps_at_capacity() { + time::pause(); + + let mut p = seeded_pacer(100, 5, 0.0, 4); + + // Put pacer in a depleted state. + p.tokens = 0.0; + p.last = Instant::now(); + + time::advance(Duration::from_secs(1)).await; + + p.refill(); + assert_eq!(p.tokens, 5.0); + } +} diff --git a/proxy_netbench/src/cmd/run/requests/mock/mod.rs b/proxy_netbench/src/cmd/run/requests/source/mock/mod.rs similarity index 91% rename from proxy_netbench/src/cmd/run/requests/mock/mod.rs rename to proxy_netbench/src/cmd/run/requests/source/mock/mod.rs index eb709d83..844d8c35 100644 --- a/proxy_netbench/src/cmd/run/requests/mock/mod.rs +++ b/proxy_netbench/src/cmd/run/requests/source/mock/mod.rs @@ -1,3 +1,5 @@ +use std::collections::VecDeque; + use rama::{ error::OpaqueError, http::{Body, Request}, @@ -12,8 +14,6 @@ mod none; mod pypi; mod vscode; -pub mod malware; - /// Generate N random requests for a M iterations + warmup pub async fn rand_requests( sync_storage: storage::SyncCompactDataStorage, @@ -22,7 +22,7 @@ pub async fn rand_requests( request_count_warmup: usize, products: Option, malware_ratio: f64, -) -> Result<(Vec>, Vec), OpaqueError> { +) -> Result<(Vec>, VecDeque), OpaqueError> { let products = products.unwrap_or_else(default_product_values); tracing::info!( "using products: {}", @@ -73,8 +73,8 @@ async fn rand_requests_inner( malware_ratio: f64, vscode: &mut self::vscode::VSCodeUriGenerator, pypi: &mut self::pypi::PyPIUriGenerator, -) -> Result, OpaqueError> { - let mut requests = Vec::with_capacity(request_count); +) -> Result, OpaqueError> { + let mut requests = VecDeque::with_capacity(request_count); let weights: Vec<_> = products.iter().map(|p| p.quality.as_u16()).collect(); let dist = WeightedIndex::new(&weights).unwrap(); @@ -89,7 +89,7 @@ async fn rand_requests_inner( let mut req = Request::new(Body::empty()); *req.uri_mut() = uri; - requests.push(req); + requests.push_back(req); } Ok(requests) diff --git a/proxy_netbench/src/cmd/run/requests/mock/none.rs b/proxy_netbench/src/cmd/run/requests/source/mock/none.rs similarity index 100% rename from proxy_netbench/src/cmd/run/requests/mock/none.rs rename to proxy_netbench/src/cmd/run/requests/source/mock/none.rs diff --git a/proxy_netbench/src/cmd/run/requests/mock/pypi.rs b/proxy_netbench/src/cmd/run/requests/source/mock/pypi.rs similarity index 97% rename from proxy_netbench/src/cmd/run/requests/mock/pypi.rs rename to proxy_netbench/src/cmd/run/requests/source/mock/pypi.rs index d5c39178..95ad3f0c 100644 --- a/proxy_netbench/src/cmd/run/requests/mock/pypi.rs +++ b/proxy_netbench/src/cmd/run/requests/source/mock/pypi.rs @@ -10,7 +10,7 @@ use safechain_proxy_lib::{ storage::SyncCompactDataStorage, }; -use super::malware::download_malware_list_for_uri; +use crate::http::malware::download_malware_list_for_uri; #[derive(Debug)] pub(super) struct PyPIUriGenerator { diff --git a/proxy_netbench/src/cmd/run/requests/mock/vscode.rs b/proxy_netbench/src/cmd/run/requests/source/mock/vscode.rs similarity index 98% rename from proxy_netbench/src/cmd/run/requests/mock/vscode.rs rename to proxy_netbench/src/cmd/run/requests/source/mock/vscode.rs index 17745c2f..0f2f7137 100644 --- a/proxy_netbench/src/cmd/run/requests/mock/vscode.rs +++ b/proxy_netbench/src/cmd/run/requests/source/mock/vscode.rs @@ -10,7 +10,7 @@ use safechain_proxy_lib::{ storage::SyncCompactDataStorage, }; -use super::malware::download_malware_list_for_uri; +use crate::http::malware::download_malware_list_for_uri; #[derive(Debug)] pub(super) struct VSCodeUriGenerator { diff --git a/proxy_netbench/src/cmd/run/requests/source/mod.rs b/proxy_netbench/src/cmd/run/requests/source/mod.rs new file mode 100644 index 00000000..562397a8 --- /dev/null +++ b/proxy_netbench/src/cmd/run/requests/source/mod.rs @@ -0,0 +1,666 @@ +use std::{collections::VecDeque, time::Duration}; + +use rama::http::Request; + +use crate::cmd::run::requests::generator::{GenState, GeneratedRequest}; + +#[cfg(not(test))] +use crate::http::har::HarEntry; + +#[cfg(test)] +pub(super) use self::tests::support::{HarEntry, HarRequest}; + +pub mod mock; +pub mod replay; + +/// Cursor tracks iteration and index. +/// Interpretation depends on the source and phase. +/// +/// For sources that consume requests, `index` represents how many requests have been emitted +/// in the current iteration, not an index into a fixed list. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(super) struct Cursor { + iteration: usize, + index: usize, +} + +impl Cursor { + pub(super) fn new() -> Self { + Self { + iteration: 0, + index: 0, + } + } + + pub(super) fn reset_for_main(&mut self) { + self.iteration = 0; + self.index = 0; + } + + pub(super) fn reset_index(&mut self) { + self.index = 0; + } +} + +/// A small plan object that tells the generator how to delay and what it is about to emit. +/// +/// It is computed without mutating state. +/// This makes it easy to reason about and test. +pub(super) struct NextPlan { + pub(super) delay_kind: DelayKind, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum DelayKind { + /// Use the RPS pacer. + Rps, + + /// Sleep until `base + start_offset`. + ReplayEmulation { start_offset: Duration }, +} + +/// Source wrapper. +/// +/// The enum delegates to concrete structs. +/// This avoids very large match blocks and keeps source specific logic close to the data it needs. +pub(super) enum Source { + Mock(MockSource), + Replay(ReplaySource), +} + +impl Source { + pub(super) fn warmup_is_done(&self, cursor: &Cursor) -> bool { + match self { + Source::Mock(s) => s.warmup_is_done(cursor), + Source::Replay(s) => s.warmup_is_done(cursor), + } + } + + pub(super) fn plan_next(&self, state: &GenState) -> Option { + match self { + Source::Mock(s) => s.plan_next(state), + Source::Replay(s) => s.plan_next(state), + } + } + + pub(super) fn produce_next( + &mut self, + state: &mut GenState, + plan: NextPlan, + ) -> Option { + let _ = plan; + match self { + Source::Mock(s) => s.produce_next(state), + Source::Replay(s) => s.produce_next(state), + } + } +} + +/// Mock source. +/// +/// This source consumes requests as they are emitted. +/// Rationale: mock requests are not reused, so cloning is wasted work. +/// +/// `requests` is a list of iterations, each iteration is a queue of requests. +/// `warmup` is a queue emitted during warmup. +pub struct MockSource { + pub requests: Vec>, + pub warmup: VecDeque, +} + +impl MockSource { + fn warmup_is_done(&self, _cursor: &Cursor) -> bool { + self.warmup.is_empty() + } + + fn plan_next(&self, state: &GenState) -> Option { + if state.is_warmup() { + if self.warmup.is_empty() { + return None; + } + return Some(NextPlan { + delay_kind: DelayKind::Rps, + }); + } + + // Main phase. + // Requests are consumed, so we search forward for any remaining iteration with data. + let it = self.next_non_empty_iteration_from(state.cursor.iteration)?; + let _ = it; + + Some(NextPlan { + delay_kind: DelayKind::Rps, + }) + } + + fn next_non_empty_iteration_from(&self, from: usize) -> Option { + let mut it = from; + while it < self.requests.len() { + if !self.requests[it].is_empty() { + return Some(it); + } + it += 1; + } + None + } + + fn produce_next(&mut self, state: &mut GenState) -> Option { + if state.is_warmup() { + let idx = state.cursor.index; + let req = self.warmup.pop_front()?; + state.cursor.index += 1; + + return Some(GeneratedRequest { + req, + index: idx, + iteration: 0, + warmup: true, + }); + } + + loop { + let it = state.cursor.iteration; + let seq = self.requests.get_mut(it)?; + + if seq.is_empty() { + // Move to next iteration and reset per iteration index. + state.cursor.iteration += 1; + state.cursor.reset_index(); + continue; + } + + let idx = state.cursor.index; + let req = seq.pop_front()?; + state.cursor.index += 1; + + return Some(GeneratedRequest { + req, + index: idx, + iteration: it, + warmup: false, + }); + } + } +} + +/// Replay source. +/// +/// `entries` is the recorded entry list. +/// `iterations` controls how many times the list is replayed in the main phase. +/// `warmup_iterations` controls how many times the list is replayed during warmup. +/// +/// Replay pacing +/// Warmup always uses RPS pacing. +/// Main uses replay emulation if configured, otherwise uses RPS pacing. +pub struct ReplaySource { + pub entries: Vec, + pub iterations: usize, + pub warmup_iterations: usize, + pub emulate_timing: bool, +} + +impl ReplaySource { + fn warmup_is_done(&self, cursor: &Cursor) -> bool { + if self.warmup_iterations == 0 { + return true; + } + if self.entries.is_empty() { + return true; + } + + let total = self.entries.len() * self.warmup_iterations; + let current = cursor.iteration * self.entries.len() + cursor.index; + current >= total + } + + fn plan_next(&self, state: &GenState) -> Option { + if self.entries.is_empty() { + return None; + } + + if state.is_warmup() { + if self.warmup_is_done(&state.cursor) { + return None; + } + + return Some(NextPlan { + delay_kind: DelayKind::Rps, + }); + } + + if state.cursor.iteration >= self.iterations { + return None; + } + + if state.cursor.index >= self.entries.len() { + return None; + } + + if self.emulate_timing { + // `start_offset` is stored in microseconds. + let start_offset_micros = self.entries.get(state.cursor.index)?.start_offset; + return Some(NextPlan { + delay_kind: DelayKind::ReplayEmulation { + start_offset: Duration::from_micros(start_offset_micros), + }, + }); + } + + Some(NextPlan { + delay_kind: DelayKind::Rps, + }) + } + + fn produce_next(&mut self, state: &mut GenState) -> Option { + if self.entries.is_empty() { + return None; + } + + if state.is_warmup() { + let req_count = self.entries.len(); + + let it_before = state.cursor.iteration; + let idx = state.cursor.index; + let req = self.entries.get(idx)?.request.clone_as_http_request(); + + state.cursor.index += 1; + if state.cursor.index >= req_count { + state.cursor.iteration += 1; + state.cursor.index = 0; + } + + // Return the iteration value for the request we actually emitted. + return Some(GeneratedRequest { + req, + index: idx, + iteration: it_before, + warmup: true, + }); + } + + loop { + if state.cursor.iteration >= self.iterations { + return None; + } + + if state.cursor.index >= self.entries.len() { + state.cursor.iteration += 1; + state.cursor.index = 0; + + // Reset base instant at iteration boundaries so offsets remain relative per iteration. + state.replay_base_instant = None; + continue; + } + + let it = state.cursor.iteration; + let idx = state.cursor.index; + let req = self.entries.get(idx)?.request.clone_as_http_request(); + + state.cursor.index += 1; + + return Some(GeneratedRequest { + req, + index: idx, + iteration: it, + warmup: false, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::VecDeque; + + use rama::http::Body; + use tokio::time::Instant; + + /// These tests are intended to be small and stable. + /// They validate edge cases and cursor advancement rather than request contents. + /// + /// The production code uses the real HarEntry type. + /// For tests we provide a minimal stand in so the tests do not depend on HAR parsing details. + pub(super) mod support { + use super::*; + + #[derive(Clone)] + pub struct HarRequest; + + impl HarRequest { + pub fn clone_as_http_request(&self) -> Request { + super::dummy_request() + } + } + + #[derive(Clone)] + pub struct HarEntry { + pub start_offset: u64, + pub request: HarRequest, + } + } + + /// Adjust this in one place if your Request body type differs. + fn dummy_request() -> Request { + Request::builder() + .uri("http://localhost/") + .body(Body::empty()) + .expect("build request") + } + + /// Minimal local GenState substitute. + /// + /// If your real GenState is easy to construct, you can remove this and use the real one. + /// These tests only need: is_warmup, cursor, replay_base_instant. + #[derive(Default)] + struct TestGenState { + warmup: bool, + cursor: Cursor, + replay_base_instant: Option, + } + + impl TestGenState { + fn warmup() -> Self { + Self { + warmup: true, + cursor: Cursor::new(), + replay_base_instant: None, + } + } + + fn main() -> Self { + Self { + warmup: false, + cursor: Cursor::new(), + replay_base_instant: Some(Instant::now()), + } + } + + fn is_warmup(&self) -> bool { + self.warmup + } + } + + // These adapters let us call the same impl logic without depending on your real GenState layout. + // Keep them in tests so production code stays simple. + trait StateLike { + #[expect(unused)] // reserved for later + fn is_warmup(&self) -> bool; + + fn cursor(&self) -> &Cursor; + fn cursor_mut(&mut self) -> &mut Cursor; + fn replay_base_mut(&mut self) -> &mut Option; + } + + impl StateLike for TestGenState { + fn is_warmup(&self) -> bool { + self.is_warmup() + } + fn cursor(&self) -> &Cursor { + &self.cursor + } + fn cursor_mut(&mut self) -> &mut Cursor { + &mut self.cursor + } + fn replay_base_mut(&mut self) -> &mut Option { + &mut self.replay_base_instant + } + } + + // Local copies of the methods we want to test, wired to TestGenState. + // This keeps tests independent of the real GenState definition. + impl MockSource { + fn plan_next_test(&self, state: &TestGenState) -> Option { + if state.is_warmup() { + if self.warmup.is_empty() { + return None; + } + return Some(NextPlan { + delay_kind: DelayKind::Rps, + }); + } + + let it = self.next_non_empty_iteration_from(state.cursor().iteration)?; + let _ = it; + + Some(NextPlan { + delay_kind: DelayKind::Rps, + }) + } + + fn produce_next_test(&mut self, state: &mut TestGenState) -> Option<(usize, usize, bool)> { + if state.is_warmup() { + let idx = state.cursor().index; + let _req = self.warmup.pop_front()?; + state.cursor_mut().index += 1; + return Some((idx, 0, true)); + } + + loop { + let it = state.cursor().iteration; + let seq = self.requests.get_mut(it)?; + + if seq.is_empty() { + state.cursor_mut().iteration += 1; + state.cursor_mut().reset_index(); + continue; + } + + let idx = state.cursor().index; + let _req = seq.pop_front()?; + state.cursor_mut().index += 1; + + return Some((idx, it, false)); + } + } + } + + impl ReplaySource { + fn plan_next_test(&self, state: &TestGenState) -> Option { + if self.entries.is_empty() { + return None; + } + + if state.is_warmup() { + if self.warmup_is_done(state.cursor()) { + return None; + } + return Some(DelayKind::Rps); + } + + if state.cursor().iteration >= self.iterations { + return None; + } + if state.cursor().index >= self.entries.len() { + return None; + } + + if self.emulate_timing { + let micros = self.entries.get(state.cursor().index)?.start_offset; + return Some(DelayKind::ReplayEmulation { + start_offset: Duration::from_micros(micros), + }); + } + + Some(DelayKind::Rps) + } + + fn produce_next_main_test(&mut self, state: &mut TestGenState) -> Option<(usize, usize)> { + loop { + if state.cursor().iteration >= self.iterations { + return None; + } + + if state.cursor().index >= self.entries.len() { + state.cursor_mut().iteration += 1; + state.cursor_mut().index = 0; + *state.replay_base_mut() = None; + continue; + } + + let it = state.cursor().iteration; + let idx = state.cursor().index; + + let _req = self.entries.get(idx)?.request.clone_as_http_request(); + state.cursor_mut().index += 1; + + return Some((idx, it)); + } + } + } + + #[tokio::test(flavor = "current_thread")] + async fn mock_warmup_consumes_requests_and_increments_index() { + let mut src = MockSource { + warmup: VecDeque::from([dummy_request(), dummy_request()]), + requests: vec![], + }; + + let state = TestGenState::warmup(); + assert!(src.plan_next_test(&state).is_some()); + + let mut state = state; + let (idx0, it0, warmup0) = src.produce_next_test(&mut state).expect("first"); + assert_eq!((idx0, it0, warmup0), (0, 0, true)); + + let (idx1, it1, warmup1) = src.produce_next_test(&mut state).expect("second"); + assert_eq!((idx1, it1, warmup1), (1, 0, true)); + + assert!(src.warmup.is_empty()); + assert!(src.plan_next_test(&state).is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn mock_main_skips_empty_iterations_and_resets_index() { + let mut src = MockSource { + warmup: VecDeque::new(), + requests: vec![ + VecDeque::new(), // empty iteration should be skipped + VecDeque::from([dummy_request(), dummy_request()]), + VecDeque::from([dummy_request()]), + ], + }; + + let mut state = TestGenState::main(); + + // First should come from iteration 1, index 0. + let (idx0, it0, warmup0) = src.produce_next_test(&mut state).expect("first"); + assert_eq!((idx0, it0, warmup0), (0, 1, false)); + + // Next from iteration 1, index 1. + let (idx1, it1, warmup1) = src.produce_next_test(&mut state).expect("second"); + assert_eq!((idx1, it1, warmup1), (1, 1, false)); + + // Next should move to iteration 2 with index reset to 0. + let (idx2, it2, warmup2) = src.produce_next_test(&mut state).expect("third"); + assert_eq!((idx2, it2, warmup2), (0, 2, false)); + + // Exhausted. + assert!(src.produce_next_test(&mut state).is_none()); + } + + #[test] + fn replay_warmup_is_done_math() { + use support::{HarEntry, HarRequest}; + + let src = ReplaySource { + entries: vec![ + HarEntry { + start_offset: 0, + request: HarRequest, + }, + HarEntry { + start_offset: 0, + request: HarRequest, + }, + ], + iterations: 1, + warmup_iterations: 2, + emulate_timing: false, + }; + + // total = 2 entries * 2 warmup iterations = 4 + assert!(!src.warmup_is_done(&Cursor { + iteration: 0, + index: 0 + })); + assert!(!src.warmup_is_done(&Cursor { + iteration: 1, + index: 1 + })); // 1*2+1 = 3 + assert!(src.warmup_is_done(&Cursor { + iteration: 2, + index: 0 + })); // 2*2+0 = 4 + } + + #[test] + fn replay_plan_next_selects_delay_kind() { + use support::{HarEntry, HarRequest}; + + let entries = vec![ + HarEntry { + start_offset: 123, + request: HarRequest, + }, + HarEntry { + start_offset: 456, + request: HarRequest, + }, + ]; + + let src = ReplaySource { + entries: entries.clone(), + iterations: 1, + warmup_iterations: 0, + emulate_timing: true, + }; + + let mut state = TestGenState::main(); + state.cursor.index = 1; + + match src.plan_next_test(&state).expect("plan") { + DelayKind::ReplayEmulation { start_offset } => { + assert_eq!(start_offset, Duration::from_micros(456)); + } + _ => panic!("expected replay emulation"), + } + + let src2 = ReplaySource { + entries, + iterations: 1, + warmup_iterations: 0, + emulate_timing: false, + }; + + assert_eq!(src2.plan_next_test(&state).expect("plan"), DelayKind::Rps); + } + + #[tokio::test(flavor = "current_thread")] + async fn replay_main_iteration_rollover_resets_replay_base() { + use support::{HarEntry, HarRequest}; + + let mut src = ReplaySource { + entries: vec![HarEntry { + start_offset: 0, + request: HarRequest, + }], + iterations: 2, + warmup_iterations: 0, + emulate_timing: false, + }; + + let mut state = TestGenState::main(); + state.replay_base_instant = Some(Instant::now()); + + // Emit the single entry in iteration 0. + let (_idx0, it0) = src.produce_next_main_test(&mut state).expect("first"); + assert_eq!(it0, 0); + + // Now cursor.index == len, next call rolls to iteration 1 and clears base. + let (_idx1, it1) = src.produce_next_main_test(&mut state).expect("second"); + assert_eq!(it1, 1); + assert!(state.replay_base_instant.is_none()); + } +} diff --git a/proxy_netbench/src/cmd/run/requests/replay.rs b/proxy_netbench/src/cmd/run/requests/source/replay.rs similarity index 100% rename from proxy_netbench/src/cmd/run/requests/replay.rs rename to proxy_netbench/src/cmd/run/requests/source/replay.rs diff --git a/proxy_netbench/src/config/scenario.rs b/proxy_netbench/src/config/scenario.rs index b1fe26c0..814b82f2 100644 --- a/proxy_netbench/src/config/scenario.rs +++ b/proxy_netbench/src/config/scenario.rs @@ -29,7 +29,7 @@ impl Scenario { // Smooth request generation with no randomness. let concurrency = utils::env::compute_concurrent_request_count() as u32; ClientConfig { - target_rps: Some(50 * concurrency), + target_rps: Some(5 * concurrency), concurrency: Some(concurrency), jitter: None, burst_size: Some(1), @@ -40,7 +40,7 @@ impl Scenario { // Requests are sent at an uneven pace. // This introduces burstiness and queue formation. ClientConfig { - target_rps: Some(5000), + target_rps: Some(500), concurrency: Some(100), jitter: Some(0.005), burst_size: Some(2), @@ -50,7 +50,7 @@ impl Scenario { Scenario::FlakyUpstream => { // Client side jitter is higher to simulate unstable producers. ClientConfig { - target_rps: Some(2500), + target_rps: Some(250), concurrency: Some(50), jitter: Some(0.01), burst_size: Some(2), diff --git a/proxy_netbench/src/cmd/run/requests/mock/malware.rs b/proxy_netbench/src/http/malware.rs similarity index 100% rename from proxy_netbench/src/cmd/run/requests/mock/malware.rs rename to proxy_netbench/src/http/malware.rs diff --git a/proxy_netbench/src/http/mod.rs b/proxy_netbench/src/http/mod.rs index dbbb5326..a6d121e3 100644 --- a/proxy_netbench/src/http/mod.rs +++ b/proxy_netbench/src/http/mod.rs @@ -9,6 +9,7 @@ use rama::{ }; pub mod har; +pub mod malware; macro_rules! impl_typed_usize_header { ($t:ident, $name:literal) => { diff --git a/proxy_netbench/src/main.rs b/proxy_netbench/src/main.rs index 650afa4c..4980962d 100644 --- a/proxy_netbench/src/main.rs +++ b/proxy_netbench/src/main.rs @@ -100,7 +100,7 @@ where graceful.spawn_task_fn(async move |guard| { let result = match args.cmds { - CliCommands::Run(run_args) => self::cmd::run::exec(args.data, run_args).await, + CliCommands::Run(run_args) => self::cmd::run::exec(args.data, guard, run_args).await, CliCommands::Mock(mock_args) => { self::cmd::mock::exec(args.data, guard, mock_args).await } From 1c27be5b521454e4f285ed9aa9566557fdd69858 Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 29 Jan 2026 22:58:50 +0100 Subject: [PATCH 25/52] finish web client code for benching --- proxy/src/client/mock_client/mod.rs | 1 + proxy/src/client/mod.rs | 27 +++- proxy/src/client/transport.rs | 22 +++- proxy/src/firewall/mod.rs | 6 +- proxy/src/firewall/notifier.rs | 4 +- proxy/src/server/proxy/client.rs | 5 +- proxy/src/server/proxy/forwarder.rs | 4 +- .../e2e/test_proxy/report_blocked_events.rs | 12 +- proxy_netbench/src/cmd/run/client.rs | 11 +- proxy_netbench/src/cmd/run/mod.rs | 120 ++++++++++++++++-- proxy_netbench/src/main.rs | 12 ++ 11 files changed, 197 insertions(+), 27 deletions(-) diff --git a/proxy/src/client/mock_client/mod.rs b/proxy/src/client/mock_client/mod.rs index df37261a..2e49a422 100644 --- a/proxy/src/client/mock_client/mod.rs +++ b/proxy/src/client/mock_client/mod.rs @@ -25,6 +25,7 @@ static ASSERT_ENDPOINT_STATE: LazyLock = pub fn new_mock_client( exec: Executor, + _cfg: super::WebClientConfig, ) -> Result + Clone, OpaqueError> { let echo_svc_builder = EchoServiceBuilder::default(); let echo_svc = Arc::new(echo_svc_builder.build_http(exec)); diff --git a/proxy/src/client/mod.rs b/proxy/src/client/mod.rs index 799834f0..ae816743 100644 --- a/proxy/src/client/mod.rs +++ b/proxy/src/client/mod.rs @@ -17,6 +17,7 @@ use ::{ http::{Request, Response, Version, client::EasyHttpWebClient}, net::client::pool::http::HttpPooledConnectorConfig, rt::Executor, + telemetry::tracing, }, std::time::Duration, }; @@ -29,15 +30,39 @@ pub use self::mock_client::new_mock_client as new_web_client; pub mod transport; +#[derive(Debug, Default)] +pub struct WebClientConfig { + #[cfg(all(not(test), feature = "bench"))] + pub do_not_allow_overwrite: bool, +} + +impl WebClientConfig { + pub fn without_overwrites() -> Self { + Self { + #[cfg(all(not(test), feature = "bench"))] + do_not_allow_overwrite: true, + } + } +} + /// Create a new web client that can be cloned and shared. #[cfg(not(test))] pub fn new_web_client( exec: Executor, + cfg: WebClientConfig, ) -> Result + Clone, OpaqueError> { + tracing::trace!("new_web_client w/ cfg: {cfg:?}"); + let max_active = crate::utils::env::compute_concurrent_request_count(); let max_total = max_active * 2; - let tcp_connector = self::transport::new_tcp_connector(exec.clone()); + let tcp_connector = self::transport::new_tcp_connector( + exec.clone(), + self::transport::TcpConnectorConfig { + #[cfg(all(not(test), feature = "bench"))] + do_not_allow_overwrite: cfg.do_not_allow_overwrite, + }, + ); let tls_config = self::transport::new_tls_connector_config()?; Ok(EasyHttpWebClient::connector_builder() diff --git a/proxy/src/client/transport.rs b/proxy/src/client/transport.rs index e5c21779..437c883e 100644 --- a/proxy/src/client/transport.rs +++ b/proxy/src/client/transport.rs @@ -9,8 +9,14 @@ pub type TcpConnector = tcp::client::service::TcpConnector< TcpStreamConnectorCloneFactory, >; -pub fn new_tcp_connector(exec: Executor) -> TcpConnector { - tcp::client::service::TcpConnector::new(exec).with_connector(TcpStreamConnector::new()) +#[derive(Debug, Default)] +pub struct TcpConnectorConfig { + #[cfg(all(not(test), feature = "bench"))] + pub do_not_allow_overwrite: bool, +} + +pub fn new_tcp_connector(exec: Executor, cfg: TcpConnectorConfig) -> TcpConnector { + tcp::client::service::TcpConnector::new(exec).with_connector(TcpStreamConnector::new(cfg)) } #[cfg(not(any(test, feature = "bench")))] @@ -22,7 +28,7 @@ mod production { impl TcpStreamConnector { #[inline(always)] - pub(super) fn new() -> Self { + pub(super) fn new(_cfg: super::TcpConnectorConfig) -> Self { Self } } @@ -83,8 +89,14 @@ mod bench { pub struct TcpStreamConnector(Option); impl TcpStreamConnector { - #[inline(always)] - pub(super) fn new() -> Self { + pub(super) fn new(cfg: super::TcpConnectorConfig) -> Self { + tracing::trace!("TcpStreamConnector w/ cfg: {cfg:?}"); + + #[cfg(all(not(test), feature = "bench"))] + if cfg.do_not_allow_overwrite { + return Self(None); + } + Self(get_egress_address_overwrite()) } } diff --git a/proxy/src/firewall/mod.rs b/proxy/src/firewall/mod.rs index 4652b656..39766f12 100644 --- a/proxy/src/firewall/mod.rs +++ b/proxy/src/firewall/mod.rs @@ -55,7 +55,11 @@ impl Firewall { reporting_endpoint: Option, ) -> Result { let exec = Executor::graceful(guard.clone()); - let inner_https_client = crate::client::new_web_client(exec.clone())?; + + let inner_https_client = crate::client::new_web_client( + exec.clone(), + crate::client::WebClientConfig::without_overwrites(), + )?; let shared_remote_malware_client = ( MapResponseBodyLayer::new(Body::new), diff --git a/proxy/src/firewall/notifier.rs b/proxy/src/firewall/notifier.rs index 2632e5f9..d69b9d79 100644 --- a/proxy/src/firewall/notifier.rs +++ b/proxy/src/firewall/notifier.rs @@ -61,7 +61,9 @@ impl std::fmt::Debug for EventNotifier { impl EventNotifier { pub fn try_new(exec: Executor, reporting_endpoint: Uri) -> Result { - let client = crate::client::new_web_client(exec.clone())?.boxed(); + let client = + crate::client::new_web_client(exec.clone(), crate::client::WebClientConfig::default())? + .boxed(); let limit = Arc::new(Semaphore::const_new(env::compute_concurrent_request_count())); let dedup = moka::sync::CacheBuilder::new(MAX_EVENTS) .time_to_live(EVENT_DEDUP_WINDOW) diff --git a/proxy/src/server/proxy/client.rs b/proxy/src/server/proxy/client.rs index 2ad9e548..98b34f8b 100644 --- a/proxy/src/server/proxy/client.rs +++ b/proxy/src/server/proxy/client.rs @@ -46,7 +46,10 @@ pub(super) fn new_https_client( ), upstream_proxy_address.map(AddInputExtensionLayer::new), ) - .into_layer(crate::client::new_web_client(exec)?); + .into_layer(crate::client::new_web_client( + exec, + crate::client::WebClientConfig::default(), + )?); Ok(HttpClient { inner }) } diff --git a/proxy/src/server/proxy/forwarder.rs b/proxy/src/server/proxy/forwarder.rs index 3550ddda..89f3de59 100644 --- a/proxy/src/server/proxy/forwarder.rs +++ b/proxy/src/server/proxy/forwarder.rs @@ -17,7 +17,7 @@ use rama::{ tcp::client::Request, }; -use crate::client::transport::{TcpConnector, new_tcp_connector}; +use crate::client::transport::{TcpConnector, TcpConnectorConfig, new_tcp_connector}; enum ForwarderKind { Direct(TcpConnector), @@ -39,7 +39,7 @@ impl fmt::Debug for TcpForwarder { impl TcpForwarder { pub(super) fn new(exec: Executor, proxy: Option) -> Self { - let tcp_connector = new_tcp_connector(exec); + let tcp_connector = new_tcp_connector(exec, TcpConnectorConfig::default()); let kind = match proxy { Some(proxy_addr) => { let connector = ProxyConnectorLayer::required( diff --git a/proxy/src/test/e2e/test_proxy/report_blocked_events.rs b/proxy/src/test/e2e/test_proxy/report_blocked_events.rs index 7e1ddd81..a7fca8a7 100644 --- a/proxy/src/test/e2e/test_proxy/report_blocked_events.rs +++ b/proxy/src/test/e2e/test_proxy/report_blocked_events.rs @@ -10,7 +10,11 @@ use crate::test::e2e; #[tokio::test] #[tracing_test::traced_test] async fn test_report_blocked_events_posts_json_to_endpoint() { - let capture_client = crate::client::new_web_client(Executor::default()).unwrap(); + let capture_client = crate::client::new_web_client( + Executor::default(), + crate::client::WebClientConfig::default(), + ) + .unwrap(); let resp = capture_client .get("http://assert-test.internal/blocked-events/clear") @@ -75,7 +79,11 @@ async fn test_report_blocked_events_posts_json_to_endpoint() { #[tokio::test] #[tracing_test::traced_test] async fn test_report_blocked_events_dedupes_same_artifact_within_30s() { - let capture_client = crate::client::new_web_client(Executor::default()).unwrap(); + let capture_client = crate::client::new_web_client( + Executor::default(), + crate::client::WebClientConfig::default(), + ) + .unwrap(); let resp = capture_client .get("http://assert-test.internal/blocked-events/clear") diff --git a/proxy_netbench/src/cmd/run/client.rs b/proxy_netbench/src/cmd/run/client.rs index 8666ad4c..ddab0a92 100644 --- a/proxy_netbench/src/cmd/run/client.rs +++ b/proxy_netbench/src/cmd/run/client.rs @@ -10,23 +10,26 @@ use rama::{ rt::Executor, service::BoxService, }; -use safechain_proxy_lib::client::{new_web_client, transport::try_set_egress_address_overwrite}; +use safechain_proxy_lib::client::{ + WebClientConfig, new_web_client, transport::try_set_egress_address_overwrite, +}; pub fn http_cient( exec: Executor, target: SocketAddress, proxy: bool, ) -> Result, OpaqueError> { + try_set_egress_address_overwrite(target)?; + if proxy { Ok(AddInputExtensionLayer::new(ProxyAddress { protocol: Some(Protocol::HTTP), address: target.into(), credential: None, }) - .into_layer(new_web_client(exec)?) + .into_layer(new_web_client(exec, WebClientConfig::default())?) .boxed()) } else { - try_set_egress_address_overwrite(target)?; - Ok(new_web_client(exec)?.boxed()) + Ok(new_web_client(exec, WebClientConfig::without_overwrites())?.boxed()) } } diff --git a/proxy_netbench/src/cmd/run/mod.rs b/proxy_netbench/src/cmd/run/mod.rs index 9c16c575..717a5d1a 100644 --- a/proxy_netbench/src/cmd/run/mod.rs +++ b/proxy_netbench/src/cmd/run/mod.rs @@ -1,16 +1,24 @@ -use std::{path::PathBuf, time::Duration}; +use std::{path::PathBuf, sync::Arc, time::Duration}; use rama::{ Service as _, error::{ErrorContext as _, OpaqueError}, graceful::ShutdownGuard, + http::Response, net::address::SocketAddress, rt::Executor, telemetry::tracing, }; use clap::Args; -use tokio::time::Instant; +use safechain_proxy_lib::utils::env; +use tokio::{ + sync::{ + Semaphore, + mpsc::{self, Receiver}, + }, + time::Instant, +}; use crate::{ cmd::run::requests::{ @@ -46,15 +54,15 @@ pub struct RunCommand { config: Option, /// Iteration duration - #[arg(long, value_name = "SECONDS", default_value_t = 10.)] + #[arg(long, value_name = "SECONDS", default_value_t = 5.)] duration: f64, /// Warmup duration - #[arg(long, value_name = "SECONDS", default_value_t = 5.)] + #[arg(long, value_name = "SECONDS", default_value_t = 1.)] warmup: f64, /// Amount of times we run through the samples - #[arg(long, default_value_t = 4)] + #[arg(long, default_value_t = 2)] iterations: usize, #[arg(long, value_parser = parse_product_values)] @@ -102,6 +110,25 @@ pub async fn exec( let request_count_per_iteration = (args.duration * target_rps as f64).next_up() as usize; let request_count_per_warmup = (args.warmup * target_rps as f64).next_up() as usize; + let concurrency = { + let c = merged_cfg.concurrency.unwrap_or_default(); + if c == 0 { + env::compute_concurrent_request_count() + } else { + c as usize + } + }; + + tracing::info!( + %target_rps, + %burst_size, + %jitter, + %request_count_per_iteration, + %request_count_per_warmup, + %concurrency, + "client config parameters ready", + ); + let iterations = args.iterations.max(1); let mut req_gen = match args.replay { @@ -132,16 +159,19 @@ pub async fn exec( const REPORT_INTERVAL: Duration = Duration::from_secs(1); - let mut reporter: Box = if args.json { + let reporter: Box = if args.json { const EMIT_EVENTS: bool = true; Box::new(JsonlReporter::new(REPORT_INTERVAL, EMIT_EVENTS)) } else { Box::new(HumanReporter::new(REPORT_INTERVAL)) }; - let mut cancelled = std::pin::pin!(guard.downgrade().into_cancelled()); + let (result_tx, result_rx) = mpsc::channel(concurrency * 8); + guard.spawn_task_fn(|guard| report_worker(guard, reporter, result_rx)); - let start = Instant::now(); + let mut cancelled = std::pin::pin!(guard.clone_weak().into_cancelled()); + + let concurrency = Arc::new(Semaphore::new(concurrency)); loop { let GeneratedRequest { @@ -166,8 +196,78 @@ pub async fn exec( let phase = if warmup { Phase::Warmup } else { Phase::Main }; - let req_start = Instant::now(); - let outcome = match client.serve(req).await { + let client = client.clone(); + let concurrency = concurrency.clone(); + let result_tx = result_tx.clone(); + + guard.spawn_task_fn(async move |guard| { + let _guard = tokio::select! { + _ = guard.cancelled() => { + tracing::error!("cancel wait for concurrency: guard shutdown"); + return; + } + guard_result = concurrency.acquire() => { + guard_result.expect("to always be able to acquire a semaphore guard") + } + }; + + let req_start = Instant::now(); + let result = client.serve(req).await; + if let Err(err) = result_tx + .send(ClientResult { + result, + req_start, + phase, + iteration, + index, + }) + .await + { + tracing::debug!("failed to send client result msg: {err}"); + } + }); + } +} + +struct ClientResult { + result: Result, + req_start: Instant, + phase: Phase, + iteration: usize, + index: usize, +} + +async fn report_worker( + guard: ShutdownGuard, + mut reporter: Box, + mut result_rx: Receiver, +) { + let start = Instant::now(); + + loop { + let ClientResult { + result, + req_start, + phase, + iteration, + index, + } = tokio::select! { + _ = guard.cancelled() => { + tracing::debug!("exit report worker: guard shutdown"); + return; + } + + maybe_result = result_rx.recv() => { + let Some(result) = maybe_result else { + tracing::debug!("exit report worker: result senders closed"); + return; + }; + + result + } + }; + + let outcome = match result { Ok(resp) => { let status = resp.status().as_u16(); if (200..400).contains(&status) { diff --git a/proxy_netbench/src/main.rs b/proxy_netbench/src/main.rs index 4980962d..3eb62645 100644 --- a/proxy_netbench/src/main.rs +++ b/proxy_netbench/src/main.rs @@ -6,6 +6,9 @@ use rama::{ telemetry::tracing, }; +#[cfg(target_family = "unix")] +use rama::error::ErrorContext as _; + use clap::{Parser, Subcommand}; use safechain_proxy_lib::utils; @@ -58,6 +61,11 @@ pub struct Args { #[arg(long, value_name = "SECONDS", default_value_t = 0., global = true)] /// the graceful shutdown timeout (<= 0.0 = no timeout) pub graceful: f64, + + #[cfg(target_family = "unix")] + /// Set the limit of max open file descriptors for this process and its children. + #[arg(long, value_name = "N", default_value_t = 262_144, global = true)] + pub ulimit: safechain_proxy_lib::utils::os::rlim_t, } #[derive(Debug, Clone, Subcommand)] @@ -79,6 +87,10 @@ async fn main() -> Result<(), BoxError> { })) .await?; + #[cfg(target_family = "unix")] + safechain_proxy_lib::utils::os::raise_nofile(args.ulimit) + .context("set file descriptor limit")?; + let base_shutdown_signal = graceful::default_signal(); if let Err(err) = run_with_args(base_shutdown_signal, args).await { eprintln!("🚩 exit with error: {err}"); From b508b8cd3b62885d85da7cbd8efb231ac7a85fff Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 29 Jan 2026 23:19:08 +0100 Subject: [PATCH 26/52] initial orchestrator netbench script --- justfile | 4 +- proxy_netbench/run.py | 382 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 385 insertions(+), 1 deletion(-) create mode 100755 proxy_netbench/run.py diff --git a/justfile b/justfile index cdf4a915..c40c3515 100644 --- a/justfile +++ b/justfile @@ -38,11 +38,13 @@ run-proxy *ARGS: --pretty \ {{ARGS}} -run-netbench *ARGS: +run-netbench-cli *ARGS: cargo run \ --bin netbench \ {{ARGS}} +run-netbench *ARGS: + ./proxy_netbench/run.py {{ARGS}} proxy-har-toggle: curl -v -XPOST http://127.0.0.1:8088/har/toggle diff --git a/proxy_netbench/run.py b/proxy_netbench/run.py new file mode 100755 index 00000000..3adc6149 --- /dev/null +++ b/proxy_netbench/run.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import atexit +import json +import os +import shutil +import signal +import subprocess +import sys +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple + + +@dataclass +class Proc: + name: str + popen: subprocess.Popen + + +def eprint(*args: object) -> None: + print(*args, file=sys.stderr) + + +def is_windows() -> bool: + return os.name == "nt" + + +def netbench_path() -> str: + exe = "netbench.exe" if is_windows() else "netbench" + candidate = Path("target") / "release" / exe + if candidate.exists(): + return str(candidate) + + found = shutil.which("netbench") + if found: + return found + + return str(candidate) + + +def run_build(verbose: bool) -> None: + cmd = ["cargo", "build", "--release"] + eprint("build:", " ".join(cmd)) + subprocess.run(cmd, check=True, stdout=None if verbose else subprocess.DEVNULL) + + +def start_process(name: str, argv: List[str], env: Dict[str, str]) -> Proc: + eprint(name + ":", " ".join(argv)) + p = subprocess.Popen( + argv, + stdout=subprocess.DEVNULL, + stderr=None, + env=env, + text=True, + ) + return Proc(name=name, popen=p) + + +def terminate_process(proc: Proc, timeout_s: float = 3.0) -> None: + p = proc.popen + if p.poll() is not None: + return + + try: + if is_windows(): + p.terminate() + else: + p.send_signal(signal.SIGTERM) + except Exception: + pass + + try: + p.wait(timeout=timeout_s) + return + except subprocess.TimeoutExpired: + pass + + try: + if is_windows(): + p.kill() + else: + p.send_signal(signal.SIGKILL) + except Exception: + pass + + try: + p.wait(timeout=timeout_s) + except Exception: + pass + + +def ensure_process_alive(proc: Proc) -> None: + code = proc.popen.poll() + if code is not None: + raise RuntimeError(f"{proc.name} exited early with code {code}") + + +def wait_for_file(path: Path, timeout_s: float = 10.0) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if path.exists() and path.stat().st_size > 0: + return + time.sleep(0.05) + raise RuntimeError(f"timeout waiting for file: {path}") + + +def read_addr_file(path: Path, timeout_s: float = 10.0) -> str: + wait_for_file(path, timeout_s=timeout_s) + txt = path.read_text(encoding="utf-8").strip() + if not txt: + raise RuntimeError(f"address file empty: {path}") + return txt + + +def parse_jsonl_lines(text: str) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + continue + return out + + +def pick_summary_events(events: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]: + return [e for e in events if e.get("type") == "summary"] + + +def pick_final_event(events: Iterable[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + finals = [e for e in events if e.get("type") == "final"] + return finals[-1] if finals else None + + +def safe_get(d: Dict[str, Any], *path: str, default: Any = None) -> Any: + cur: Any = d + for key in path: + if not isinstance(cur, dict): + return default + cur = cur.get(key) + return cur if cur is not None else default + + +def ascii_bar(value: float, max_value: float, width: int) -> str: + if max_value <= 0: + return " " * width + ratio = max(0.0, min(1.0, value / max_value)) + n = int(round(ratio * width)) + return "█" * n + " " * (width - n) + + +def render_human_report(events: List[Dict[str, Any]]) -> None: + summaries = pick_summary_events(events) + final = pick_final_event(events) + + if not summaries and not final: + print("no json summary lines captured") + return + + print() + print("netbench report") + print() + + rows: List[Tuple[float, str, float, int, int, int]] = [] + for s in summaries: + t_ms = float(s.get("t_ms", 0.0)) + t_s = t_ms / 1000.0 + phase = str(s.get("phase", "")) + rps = float(s.get("rps", 0.0)) + + ok = int(safe_get(s, "interval", "ok", default=0)) + conn = int(safe_get(s, "interval", "connect_fail", default=0)) + http = int(safe_get(s, "interval", "http_fail", default=0)) + rows.append((t_s, phase, rps, ok, conn, http)) + + if rows: + print("per second summary") + print("time_s phase rps ok connect_fail http_fail") + for t_s, phase, rps, ok, conn, http in rows[-20:]: + print(f"{t_s:6.1f} {phase:6s} {rps:6.1f} {ok:5d} {conn:12d} {http:9d}") + + max_rps = max(r[2] for r in rows) if rows else 0.0 + print() + print("rps graph") + for t_s, phase, rps, ok, conn, http in rows[-40:]: + bar = ascii_bar(rps, max_rps, width=32) + print(f"{t_s:6.1f} {phase:6s} {rps:6.1f} {bar} ok={ok} cf={conn} hf={http}") + + if final: + total = safe_get(final, "total", default={}) + total_total = int(safe_get(total, "total", default=0)) + total_ok = int(safe_get(total, "ok", default=0)) + total_conn = int(safe_get(total, "connect_fail", default=0)) + total_http = int(safe_get(total, "http_fail", default=0)) + total_other = int(safe_get(total, "other_fail", default=0)) + + print() + print("final totals") + print( + f"total={total_total} ok={total_ok} connect_fail={total_conn} " + f"http_fail={total_http} other_fail={total_other}" + ) + + if total_total > 0: + ok_rate = 100.0 * (total_ok / total_total) + fail_rate = 100.0 * ((total_total - total_ok) / total_total) + print(f"ok_rate={ok_rate:.2f}% fail_rate={fail_rate:.2f}%") + print() + + +def render_diff_friendly_summary(events: List[Dict[str, Any]]) -> str: + final = pick_final_event(events) + if not final: + return "no_final_event=1\n" + + total = safe_get(final, "total", default={}) + total_total = int(safe_get(total, "total", default=0)) + total_ok = int(safe_get(total, "ok", default=0)) + total_conn = int(safe_get(total, "connect_fail", default=0)) + total_http = int(safe_get(total, "http_fail", default=0)) + total_other = int(safe_get(total, "other_fail", default=0)) + + lines = [] + lines.append(f"total={total_total}") + lines.append(f"ok={total_ok}") + lines.append(f"connect_fail={total_conn}") + lines.append(f"http_fail={total_http}") + lines.append(f"other_fail={total_other}") + return "\n".join(lines) + "\n" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--with-proxy", action="store_true") + ap.add_argument( + "--scenario", + default="baseline", + choices=["baseline", "latency-jitter", "flaky-upstream"], + ) + ap.add_argument( + "--report-file", default=None, help="write JSONL report to file for diffing" + ) + ap.add_argument("--verbose", action="store_true") + + args = ap.parse_args() + + run_build(verbose=args.verbose) + netbench = netbench_path() + + data_dir = Path(tempfile.mkdtemp(prefix="safechain-netbench-")) + eprint("data-dir:", str(data_dir)) + + env = dict(os.environ) + env.setdefault("RUST_LOG", "debug" if args.verbose else "info") + + procs: List[Proc] = [] + + def cleanup() -> None: + for pr in reversed(procs): + terminate_process(pr) + + # Keep data dir if user asked for a report file, so artifacts remain inspectable. + if args.report_file: + return + + try: + shutil.rmtree(data_dir) + except Exception: + pass + + atexit.register(cleanup) + + # Start mock server, let it bind to a free port and write address file. + mock_argv = [ + netbench, + "mock", + "--scenario", + args.scenario, + "--data", + str(data_dir), + ] + mock_proc = start_process("mock", mock_argv, env) + procs.append(mock_proc) + + mock_addr_file = data_dir / "netbench.mock.addr.txt" + mock_addr = read_addr_file(mock_addr_file, timeout_s=15.0) + ensure_process_alive(mock_proc) + eprint("mock addr:", mock_addr) + + # Start proxy optionally + if args.with_proxy: + proxy_argv = [ + netbench, + "proxy", + "--data", + str(data_dir), + mock_addr, + ] + proxy_proc = start_process("proxy", proxy_argv, env) + procs.append(proxy_proc) + + proxy_addr_file = data_dir / "proxy.addr.txt" + proxy_addr = read_addr_file(proxy_addr_file, timeout_s=15.0) + ensure_process_alive(proxy_proc) + eprint("proxy addr:", proxy_addr) + target_addr = proxy_addr + else: + target_addr = mock_addr + + # Run benchmarker + run_argv = [ + netbench, + "run", + "--json", + "--scenario", + args.scenario, + "--data", + str(data_dir), + ] + if args.with_proxy: + run_argv.append("--proxy") + run_argv.append(target_addr) + + eprint("run:", " ".join(run_argv)) + completed = subprocess.run(run_argv, env=env, capture_output=True, text=True) + + stdout_text = completed.stdout or "" + stderr_text = completed.stderr or "" + + cleanup() + + if completed.returncode != 0: + if stderr_text.strip(): + eprint(stderr_text.strip()) + raise RuntimeError(f"runner exited with code {completed.returncode}") + + events = parse_jsonl_lines(stdout_text) + + if args.report_file: + report_path = Path(args.report_file) + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(stdout_text, encoding="utf-8") + + summary_path = report_path.with_suffix(report_path.suffix + ".summary.txt") + summary_path.write_text(render_diff_friendly_summary(events), encoding="utf-8") + + print(str(report_path)) + print(str(summary_path)) + print() + print("diff friendly summary") + print(summary_path.read_text(encoding="utf-8").strip()) + print() + print("data-dir kept at") + print(str(data_dir)) + return 0 + + render_human_report(events) + + if stderr_text.strip(): + print("runner stderr") + print(stderr_text.strip()) + print() + + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except KeyboardInterrupt: + raise SystemExit(130) + except Exception as exc: + eprint("error:", exc) + raise SystemExit(1) From bf456446e9595c5cefedbc44c068296ad545133b Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 29 Jan 2026 23:33:57 +0100 Subject: [PATCH 27/52] improve netbench script --- proxy_netbench/run.py | 396 ++++++++++++++++++-------- proxy_netbench/src/config/scenario.rs | 2 +- 2 files changed, 273 insertions(+), 125 deletions(-) diff --git a/proxy_netbench/run.py b/proxy_netbench/run.py index 3adc6149..85802693 100755 --- a/proxy_netbench/run.py +++ b/proxy_netbench/run.py @@ -1,4 +1,38 @@ #!/usr/bin/env python3 +""" +netbench_orchestrator.py + +Only stdlib. + +What it does +1. Builds netbench in release mode with cargo. +2. Creates a unique temp data dir for this run. +3. Starts mock server, waits until it writes its bind address file. +4. Optionally starts proxy, waits until it writes its bind address file. +5. Runs benchmarker with --json, captures JSONL stdout to a file. +6. Prints live progress lines while the runner is executing. +7. If --report-file is provided, writes the raw JSONL report and a stable summary file for diffs. +8. If no --report-file, prints a compact human report with a small ASCII graph. + +Logging +- netbench tracing output is redirected to log files using --output +- mock: {data_dir}/logs/mock.log +- proxy: {data_dir}/logs/proxy.log (if used) +- run: {data_dir}/logs/run.log + +Address discovery +- mock address from {data_dir}/netbench.mock.addr.txt +- proxy address from {data_dir}/proxy.addr.txt + +CLI +- --scenario baseline|latency-jitter|flaky-upstream +- --with-proxy +- --report-file path/to/report.jsonl +- --verbose + +Notes +- Progress lines are printed to stderr so stdout stays clean and can be piped. +""" from __future__ import annotations @@ -51,11 +85,10 @@ def run_build(verbose: bool) -> None: def start_process(name: str, argv: List[str], env: Dict[str, str]) -> Proc: - eprint(name + ":", " ".join(argv)) p = subprocess.Popen( argv, stdout=subprocess.DEVNULL, - stderr=None, + stderr=subprocess.DEVNULL, env=env, text=True, ) @@ -104,8 +137,11 @@ def ensure_process_alive(proc: Proc) -> None: def wait_for_file(path: Path, timeout_s: float = 10.0) -> None: deadline = time.time() + timeout_s while time.time() < deadline: - if path.exists() and path.stat().st_size > 0: - return + try: + if path.exists() and path.stat().st_size > 0: + return + except FileNotFoundError: + pass time.sleep(0.05) raise RuntimeError(f"timeout waiting for file: {path}") @@ -118,26 +154,14 @@ def read_addr_file(path: Path, timeout_s: float = 10.0) -> str: return txt -def parse_jsonl_lines(text: str) -> List[Dict[str, Any]]: - out: List[Dict[str, Any]] = [] - for line in text.splitlines(): - line = line.strip() - if not line: - continue - try: - out.append(json.loads(line)) - except json.JSONDecodeError: - continue - return out - - -def pick_summary_events(events: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]: - return [e for e in events if e.get("type") == "summary"] - - -def pick_final_event(events: Iterable[Dict[str, Any]]) -> Optional[Dict[str, Any]]: - finals = [e for e in events if e.get("type") == "final"] - return finals[-1] if finals else None +def parse_json_line(line: str) -> Optional[Dict[str, Any]]: + line = line.strip() + if not line: + return None + try: + return json.loads(line) + except json.JSONDecodeError: + return None def safe_get(d: Dict[str, Any], *path: str, default: Any = None) -> Any: @@ -149,6 +173,28 @@ def safe_get(d: Dict[str, Any], *path: str, default: Any = None) -> Any: return cur if cur is not None else default +def render_diff_friendly_summary_from_events(events: List[Dict[str, Any]]) -> str: + finals = [e for e in events if e.get("type") == "final"] + final = finals[-1] if finals else None + if not final: + return "no_final_event=1\n" + + total = safe_get(final, "total", default={}) + total_total = int(safe_get(total, "total", default=0)) + total_ok = int(safe_get(total, "ok", default=0)) + total_conn = int(safe_get(total, "connect_fail", default=0)) + total_http = int(safe_get(total, "http_fail", default=0)) + total_other = int(safe_get(total, "other_fail", default=0)) + + lines = [] + lines.append(f"total={total_total}") + lines.append(f"ok={total_ok}") + lines.append(f"connect_fail={total_conn}") + lines.append(f"http_fail={total_http}") + lines.append(f"other_fail={total_other}") + return "\n".join(lines) + "\n" + + def ascii_bar(value: float, max_value: float, width: int) -> str: if max_value <= 0: return " " * width @@ -157,84 +203,61 @@ def ascii_bar(value: float, max_value: float, width: int) -> str: return "█" * n + " " * (width - n) -def render_human_report(events: List[Dict[str, Any]]) -> None: - summaries = pick_summary_events(events) - final = pick_final_event(events) - - if not summaries and not final: - print("no json summary lines captured") - return +def print_runner_progress_spinner( + start_ts: float, + last_summary: Optional[Dict[str, Any]], + spin_state: int, +) -> int: + spinner = ["|", "/", "-", "\\"] + ch = spinner[spin_state % len(spinner)] + elapsed = time.time() - start_ts + + if last_summary: + phase = str(last_summary.get("phase", "")) + rps = float(last_summary.get("rps", 0.0)) + ok = int(safe_get(last_summary, "interval", "ok", default=0)) + cf = int(safe_get(last_summary, "interval", "connect_fail", default=0)) + hf = int(safe_get(last_summary, "interval", "http_fail", default=0)) + msg = ( + f"{ch} {elapsed:6.1f}s phase={phase} rps={rps:6.1f} ok={ok} cf={cf} hf={hf}" + ) + else: + msg = f"{ch} {elapsed:6.1f}s running" - print() - print("netbench report") - print() + sys.stderr.write("\r" + msg + " " * 10) + sys.stderr.flush() + return spin_state + 1 - rows: List[Tuple[float, str, float, int, int, int]] = [] - for s in summaries: - t_ms = float(s.get("t_ms", 0.0)) - t_s = t_ms / 1000.0 - phase = str(s.get("phase", "")) - rps = float(s.get("rps", 0.0)) - ok = int(safe_get(s, "interval", "ok", default=0)) - conn = int(safe_get(s, "interval", "connect_fail", default=0)) - http = int(safe_get(s, "interval", "http_fail", default=0)) - rows.append((t_s, phase, rps, ok, conn, http)) +def finalize_spinner_line() -> None: + sys.stderr.write("\r" + " " * 120 + "\r") + sys.stderr.flush() - if rows: - print("per second summary") - print("time_s phase rps ok connect_fail http_fail") - for t_s, phase, rps, ok, conn, http in rows[-20:]: - print(f"{t_s:6.1f} {phase:6s} {rps:6.1f} {ok:5d} {conn:12d} {http:9d}") - max_rps = max(r[2] for r in rows) if rows else 0.0 - print() - print("rps graph") - for t_s, phase, rps, ok, conn, http in rows[-40:]: - bar = ascii_bar(rps, max_rps, width=32) - print(f"{t_s:6.1f} {phase:6s} {rps:6.1f} {bar} ok={ok} cf={conn} hf={http}") +def format_summary_line(s: Dict[str, Any], elapsed_wall_s: float) -> str: + phase = str(s.get("phase", "")) + rps = float(s.get("rps", 0.0)) + ok = int(safe_get(s, "interval", "ok", default=0)) + cf = int(safe_get(s, "interval", "connect_fail", default=0)) + hf = int(safe_get(s, "interval", "http_fail", default=0)) - if final: - total = safe_get(final, "total", default={}) - total_total = int(safe_get(total, "total", default=0)) - total_ok = int(safe_get(total, "ok", default=0)) - total_conn = int(safe_get(total, "connect_fail", default=0)) - total_http = int(safe_get(total, "http_fail", default=0)) - total_other = int(safe_get(total, "other_fail", default=0)) + tot_ok = int(safe_get(s, "total", "ok", default=0)) + tot_total = int(safe_get(s, "total", "total", default=0)) + tot_fail = tot_total - tot_ok if tot_total >= tot_ok else 0 - print() - print("final totals") - print( - f"total={total_total} ok={total_ok} connect_fail={total_conn} " - f"http_fail={total_http} other_fail={total_other}" - ) + t_ms = float(s.get("t_ms", 0.0)) + t_s = t_ms / 1000.0 if t_ms > 0 else elapsed_wall_s - if total_total > 0: - ok_rate = 100.0 * (total_ok / total_total) - fail_rate = 100.0 * ((total_total - total_ok) / total_total) - print(f"ok_rate={ok_rate:.2f}% fail_rate={fail_rate:.2f}%") - print() - - -def render_diff_friendly_summary(events: List[Dict[str, Any]]) -> str: - final = pick_final_event(events) - if not final: - return "no_final_event=1\n" + return ( + f"[{t_s:6.1f}s] phase={phase:6s} " + f"rps={rps:7.1f} ok={ok:5d} cf={cf:4d} hf={hf:4d} " + f"total_ok={tot_ok} total_fail={tot_fail}" + ) - total = safe_get(final, "total", default={}) - total_total = int(safe_get(total, "total", default=0)) - total_ok = int(safe_get(total, "ok", default=0)) - total_conn = int(safe_get(total, "connect_fail", default=0)) - total_http = int(safe_get(total, "http_fail", default=0)) - total_other = int(safe_get(total, "other_fail", default=0)) - lines = [] - lines.append(f"total={total_total}") - lines.append(f"ok={total_ok}") - lines.append(f"connect_fail={total_conn}") - lines.append(f"http_fail={total_http}") - lines.append(f"other_fail={total_other}") - return "\n".join(lines) + "\n" +def print_progress_line(s: Dict[str, Any], start_ts: float) -> None: + elapsed = time.time() - start_ts + eprint(format_summary_line(s, elapsed)) def main() -> int: @@ -249,26 +272,34 @@ def main() -> int: "--report-file", default=None, help="write JSONL report to file for diffing" ) ap.add_argument("--verbose", action="store_true") - args = ap.parse_args() run_build(verbose=args.verbose) netbench = netbench_path() data_dir = Path(tempfile.mkdtemp(prefix="safechain-netbench-")) - eprint("data-dir:", str(data_dir)) + logs_dir = data_dir / "logs" + logs_dir.mkdir(parents=True, exist_ok=True) + + mock_log = logs_dir / "mock.log" + proxy_log = logs_dir / "proxy.log" + run_log = logs_dir / "run.log" + + run_jsonl = data_dir / "run.jsonl" + run_summary = data_dir / "run.summary.txt" env = dict(os.environ) env.setdefault("RUST_LOG", "debug" if args.verbose else "info") procs: List[Proc] = [] - def cleanup() -> None: + keep_data = bool(args.report_file) + + def cleanup(keep: bool) -> None: for pr in reversed(procs): terminate_process(pr) - # Keep data dir if user asked for a report file, so artifacts remain inspectable. - if args.report_file: + if keep: return try: @@ -276,9 +307,10 @@ def cleanup() -> None: except Exception: pass - atexit.register(cleanup) + atexit.register(lambda: cleanup(keep_data)) - # Start mock server, let it bind to a free port and write address file. + # Start mock server, traces to file + eprint("mock: starting") mock_argv = [ netbench, "mock", @@ -286,36 +318,44 @@ def cleanup() -> None: args.scenario, "--data", str(data_dir), + "--output", + str(mock_log), ] mock_proc = start_process("mock", mock_argv, env) procs.append(mock_proc) mock_addr_file = data_dir / "netbench.mock.addr.txt" - mock_addr = read_addr_file(mock_addr_file, timeout_s=15.0) + eprint("mock: waiting for address file") + mock_addr = read_addr_file(mock_addr_file, timeout_s=20.0) ensure_process_alive(mock_proc) - eprint("mock addr:", mock_addr) + eprint("mock:", mock_addr) - # Start proxy optionally + # Start proxy optionally, traces to file if args.with_proxy: + eprint("proxy: starting") proxy_argv = [ netbench, "proxy", "--data", str(data_dir), + "--output", + str(proxy_log), mock_addr, ] proxy_proc = start_process("proxy", proxy_argv, env) procs.append(proxy_proc) proxy_addr_file = data_dir / "proxy.addr.txt" - proxy_addr = read_addr_file(proxy_addr_file, timeout_s=15.0) + eprint("proxy: waiting for address file") + proxy_addr = read_addr_file(proxy_addr_file, timeout_s=20.0) ensure_process_alive(proxy_proc) - eprint("proxy addr:", proxy_addr) + eprint("proxy:", proxy_addr) target_addr = proxy_addr else: target_addr = mock_addr - # Run benchmarker + # Run benchmarker. + # We stream runner stdout to capture JSONL and show progress live. run_argv = [ netbench, "run", @@ -324,33 +364,89 @@ def cleanup() -> None: args.scenario, "--data", str(data_dir), + "--output", + str(run_log), ] if args.with_proxy: run_argv.append("--proxy") run_argv.append(target_addr) - eprint("run:", " ".join(run_argv)) - completed = subprocess.run(run_argv, env=env, capture_output=True, text=True) - - stdout_text = completed.stdout or "" - stderr_text = completed.stderr or "" + eprint("run: started") + runner = subprocess.Popen( + run_argv, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + bufsize=1, + universal_newlines=True, + ) - cleanup() + events: List[Dict[str, Any]] = [] + last_summary: Optional[Dict[str, Any]] = None + start_ts = time.time() + spin = 0 + + with run_jsonl.open("w", encoding="utf-8") as f_jsonl: + assert runner.stdout is not None + + last_spin_update = 0.0 + while True: + line = runner.stdout.readline() + if line: + f_jsonl.write(line) + f_jsonl.flush() + + ev = parse_json_line(line) + if ev is not None: + events.append(ev) + if ev.get("type") == "summary": + last_summary = ev + print_progress_line(ev, start_ts) + else: + code = runner.poll() + now = time.time() + + if now - last_spin_update >= 0.15: + spin = print_runner_progress_spinner(start_ts, last_summary, spin) + last_spin_update = now + + if code is not None: + break + + time.sleep(0.03) + + finalize_spinner_line() + + rc = runner.wait() + if rc != 0: + cleanup(keep=True) + raise RuntimeError( + "runner failed. Logs and artifacts:\n" + f"- data dir: {data_dir}\n" + f"- jsonl: {run_jsonl}\n" + f"- mock log: {mock_log}\n" + f"- proxy log: {proxy_log if args.with_proxy else '(no proxy)'}\n" + f"- run log: {run_log}\n" + ) - if completed.returncode != 0: - if stderr_text.strip(): - eprint(stderr_text.strip()) - raise RuntimeError(f"runner exited with code {completed.returncode}") + run_summary.write_text( + render_diff_friendly_summary_from_events(events), encoding="utf-8" + ) - events = parse_jsonl_lines(stdout_text) + # Stop services + cleanup(keep=keep_data) + # If user asked for a report file, copy raw JSONL and summary to requested location. if args.report_file: report_path = Path(args.report_file) report_path.parent.mkdir(parents=True, exist_ok=True) - report_path.write_text(stdout_text, encoding="utf-8") + report_path.write_text(run_jsonl.read_text(encoding="utf-8"), encoding="utf-8") summary_path = report_path.with_suffix(report_path.suffix + ".summary.txt") - summary_path.write_text(render_diff_friendly_summary(events), encoding="utf-8") + summary_path.write_text( + run_summary.read_text(encoding="utf-8"), encoding="utf-8" + ) print(str(report_path)) print(str(summary_path)) @@ -358,17 +454,69 @@ def cleanup() -> None: print("diff friendly summary") print(summary_path.read_text(encoding="utf-8").strip()) print() - print("data-dir kept at") - print(str(data_dir)) + print("logs") + print(str(mock_log)) + if args.with_proxy: + print(str(proxy_log)) + print(str(run_log)) return 0 - render_human_report(events) + # Human output + print() + print("netbench finished") + print(f"scenario={args.scenario} proxied={'yes' if args.with_proxy else 'no'}") + print() + + summaries = [e for e in events if e.get("type") == "summary"] + if summaries: + rows: List[Tuple[float, str, float, int, int, int]] = [] + for s in summaries: + t_ms = float(s.get("t_ms", 0.0)) + t_s = t_ms / 1000.0 + phase = str(s.get("phase", "")) + rps = float(s.get("rps", 0.0)) + ok = int(safe_get(s, "interval", "ok", default=0)) + cf = int(safe_get(s, "interval", "connect_fail", default=0)) + hf = int(safe_get(s, "interval", "http_fail", default=0)) + rows.append((t_s, phase, rps, ok, cf, hf)) + + print("per second summary (last 12)") + print("time_s phase rps ok connect_fail http_fail") + for t_s, phase, rps, ok, cf, hf in rows[-12:]: + print(f"{t_s:6.1f} {phase:6s} {rps:6.1f} {ok:5d} {cf:12d} {hf:9d}") - if stderr_text.strip(): - print("runner stderr") - print(stderr_text.strip()) + max_rps = max(r[2] for r in rows) if rows else 0.0 + print() + print("rps graph (last 24)") + for t_s, phase, rps, ok, cf, hf in rows[-24:]: + bar = ascii_bar(rps, max_rps, width=24) + print(f"{t_s:6.1f} {phase:6s} {rps:6.1f} {bar} ok={ok} cf={cf} hf={hf}") print() + final = next((e for e in reversed(events) if e.get("type") == "final"), None) + if final: + total = safe_get(final, "total", default={}) + total_total = int(safe_get(total, "total", default=0)) + total_ok = int(safe_get(total, "ok", default=0)) + total_cf = int(safe_get(total, "connect_fail", default=0)) + total_hf = int(safe_get(total, "http_fail", default=0)) + total_of = int(safe_get(total, "other_fail", default=0)) + print("final totals") + print( + f"total={total_total} ok={total_ok} connect_fail={total_cf} http_fail={total_hf} other_fail={total_of}" + ) + print() + + print("artifacts") + print(f"data dir: {data_dir}") + print(f"jsonl: {run_jsonl}") + print(f"summary: {run_summary}") + print(f"mock log: {mock_log}") + if args.with_proxy: + print(f"proxy log: {proxy_log}") + print(f"run log: {run_log}") + print() + return 0 diff --git a/proxy_netbench/src/config/scenario.rs b/proxy_netbench/src/config/scenario.rs index 814b82f2..a78233f0 100644 --- a/proxy_netbench/src/config/scenario.rs +++ b/proxy_netbench/src/config/scenario.rs @@ -29,7 +29,7 @@ impl Scenario { // Smooth request generation with no randomness. let concurrency = utils::env::compute_concurrent_request_count() as u32; ClientConfig { - target_rps: Some(5 * concurrency), + target_rps: Some(concurrency), concurrency: Some(concurrency), jitter: None, burst_size: Some(1), From c81a6e7e2923f5843d246fa1ab33c8aa036e8d81 Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 29 Jan 2026 23:51:29 +0100 Subject: [PATCH 28/52] improve netbencher code w/ comparison + add docs --- docs/netbench.md | 231 +++++++++++++++++++++++++++++++++++++++++- proxy_netbench/run.py | 183 ++++++++++++++++++++++++++------- 2 files changed, 374 insertions(+), 40 deletions(-) diff --git a/docs/netbench.md b/docs/netbench.md index 19c608ec..78909e2a 100644 --- a/docs/netbench.md +++ b/docs/netbench.md @@ -1,3 +1,232 @@ # Network Benchmarker (netbench) -TODO +`netbench` is a self-contained network benchmarking tool used to measure throughput, latency, error behavior, and regressions in HTTP-based systems. + +It is designed to: +- generate realistic request traffic +- optionally run traffic through a local (safechain) proxy +- replay recorded traffic from HAR files (instead of generated mock data) +- produce machine-readable output for automation +- make performance regressions easy to spot over time + +The project ships with: +- a **mock server** to simulate upstream behavior +- an optional **proxy** layer (~= safechain-proxy) +- a **benchmark runner** +- a small **orchestrator script** (`proxy_netbench/run.py`) + to wire everything together and produce reports + + +## High-level overview + +A typical benchmark run looks like this: + +``` + +client traffic +↓ +[ runner ] → (optional) → [ proxy ] → [ mock server ] + +``` + +- The **runner** generates load and measures results +- The **mock server** simulates an upstream service +- The **proxy** allows measuring overhead, blocking behavior, or recording traffic +- All components are local and start automatically + + +## Output and artifacts + +Each benchmark run creates a **temporary data directory** containing all artifacts for that run. + +You will see paths like: + +``` +/tmp/safechain-netbench-xxxxxx/ +├── netbench.mock.addr.txt +├── proxy.addr.txt (only if proxy is used) +├── run.jsonl # raw JSON lines output from runner +├── run.summary.txt # stable summary for diffing +└── logs/ + ├── mock.log + ├── proxy.log (only if proxy is used) + └── run.log +```` + +### Important files + +- **run.jsonl** + - Machine-readable benchmark output + - One JSON object per line + - Safe to parse, archive, or feed into CI + +- **run.summary.txt** + - Stable key=value format + - Designed for diffing between runs + - Used for regression detection + +- **logs/** + - Full tracing output for debugging + - Never printed to stdout by default + +--- + +## Scenarios + +Benchmarks are configured using **scenarios** instead of low-level tuning knobs. + +Available scenarios: + +| Scenario | Purpose | +|--------|--------| +| `baseline` | Ideal conditions. Measure pure overhead and regressions | +| `latency-jitter` | Variable latency. Observe queuing and tail behavior | +| `flaky-upstream` | Unstable upstream. Test error handling and resilience | + +--- + +## Running benchmarks + +The recommended way to run benchmarks is via the **orchestrator script**. + +The script: +- builds `netbench` in release mode +- starts the mock server +- optionally starts the proxy +- runs the benchmark +- shows live progress +- produces human-readable and machine-readable output + +### Basic run + +```bash +just run-netbench +```` + +Uses: + +* scenario: `baseline` +* direct connection to mock server +* live progress output +* final summary printed to console + +--- + +### Run with a different scenario + +```bash +just run-netbench --scenario latency-jitter +``` + +--- + +### Run through the proxy + +```bash +just run-netbench --with-proxy +``` + +This measures the proxy overhead and behavior. + +--- + +## Live feedback during runs + +While the benchmark is running, you will see progress lines like: + +``` +[ 12.0s] phase=main rps= 845.3 ok= 631 cf=0 hf=183 total_ok=12412 total_fail=3291 +``` + +This shows: + +* elapsed time +* current phase (warmup or main) +* requests per second +* success vs failure counts +* running totals + +This makes it easy to see if a run is healthy or stalled. + +--- + +## Saving results for regression tracking + +### Save a baseline + +```bash +just run-netbench \ + --scenario baseline \ + --save-baseline target/baselines/baseline.summary.txt +``` + +This creates a stable summary file that can be committed to git. + +--- + +### Compare against a previous run + +```bash +just run-netbench \ + --scenario baseline \ + --compare target/baselines/baseline.summary.txt +``` + +You will see a comparison section at the end: + +``` +comparison +avg_main_rps: 835.40 (+12.30, +1.5%) +ok_rate: 76.10% (-0.80pp) +total: 120000.00 (+0.00) +ok: 91320.00 (-960.00, -1.0%) +connect_fail: 0.00 (+0.00) +http_fail: 28680.00 (+960.00, +3.5%) +``` + +--- + +### Store full reports + +```bash +just run-netbench \ + --with-proxy \ + --scenario baseline \ + --report-file target/reports/baseline_proxy.jsonl +``` + +This writes: + +* raw JSONL report +* stable summary file next to it +* keeps the full data directory for inspection + +## Direct CLI usage (advanced) + +The orchestrator is recommended, but the individual components can be run manually. + +### Mock server + +```bash +netbench mock --scenario baseline + +# use `netbench mock --help` for more usage info +``` + +### Proxy + +```bash +netbench proxy + +# use `netbench proxy --help` for more usage info +``` + +### Runner + +```bash +netbench run --json --scenario baseline
+ +# use `netbench run --help` for more usage info +``` + +Manual usage is useful for debugging or integration with custom tooling. diff --git a/proxy_netbench/run.py b/proxy_netbench/run.py index 85802693..654ce412 100755 --- a/proxy_netbench/run.py +++ b/proxy_netbench/run.py @@ -1,38 +1,4 @@ #!/usr/bin/env python3 -""" -netbench_orchestrator.py - -Only stdlib. - -What it does -1. Builds netbench in release mode with cargo. -2. Creates a unique temp data dir for this run. -3. Starts mock server, waits until it writes its bind address file. -4. Optionally starts proxy, waits until it writes its bind address file. -5. Runs benchmarker with --json, captures JSONL stdout to a file. -6. Prints live progress lines while the runner is executing. -7. If --report-file is provided, writes the raw JSONL report and a stable summary file for diffs. -8. If no --report-file, prints a compact human report with a small ASCII graph. - -Logging -- netbench tracing output is redirected to log files using --output -- mock: {data_dir}/logs/mock.log -- proxy: {data_dir}/logs/proxy.log (if used) -- run: {data_dir}/logs/run.log - -Address discovery -- mock address from {data_dir}/netbench.mock.addr.txt -- proxy address from {data_dir}/proxy.addr.txt - -CLI -- --scenario baseline|latency-jitter|flaky-upstream -- --with-proxy -- --report-file path/to/report.jsonl -- --verbose - -Notes -- Progress lines are printed to stderr so stdout stays clean and can be piped. -""" from __future__ import annotations @@ -48,7 +14,7 @@ import time from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple @dataclass @@ -260,6 +226,120 @@ def print_progress_line(s: Dict[str, Any], start_ts: float) -> None: eprint(format_summary_line(s, elapsed)) +def compute_aggregate_from_events(events: List[Dict[str, Any]]) -> Dict[str, float]: + final = next((e for e in reversed(events) if e.get("type") == "final"), None) + + total_total = float(safe_get(final or {}, "total", "total", default=0)) + total_ok = float(safe_get(final or {}, "total", "ok", default=0)) + connect_fail = float(safe_get(final or {}, "total", "connect_fail", default=0)) + http_fail = float(safe_get(final or {}, "total", "http_fail", default=0)) + other_fail = float(safe_get(final or {}, "total", "other_fail", default=0)) + + ok_rate = (total_ok / total_total) if total_total > 0 else 0.0 + + summaries = [ + e for e in events if e.get("type") == "summary" and e.get("phase") == "main" + ] + if summaries: + avg_rps = sum(float(s.get("rps", 0.0)) for s in summaries) / float( + len(summaries) + ) + else: + avg_rps = 0.0 + + return { + "avg_main_rps": avg_rps, + "total": total_total, + "ok": total_ok, + "connect_fail": connect_fail, + "http_fail": http_fail, + "other_fail": other_fail, + "ok_rate": ok_rate, + } + + +def write_kv_baseline(path: Path, metrics: Dict[str, float]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + lines = [ + f"avg_main_rps={metrics.get('avg_main_rps', 0.0)}", + f"total={metrics.get('total', 0.0)}", + f"ok={metrics.get('ok', 0.0)}", + f"connect_fail={metrics.get('connect_fail', 0.0)}", + f"http_fail={metrics.get('http_fail', 0.0)}", + f"other_fail={metrics.get('other_fail', 0.0)}", + f"ok_rate={metrics.get('ok_rate', 0.0)}", + ] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def parse_kv_summary(text: str) -> Dict[str, float]: + out: Dict[str, float] = {} + for line in text.splitlines(): + line = line.strip() + if not line or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip() + try: + out[k] = float(v) + except ValueError: + continue + return out + + +def load_metrics_from_path(path: Path) -> Dict[str, float]: + text = path.read_text(encoding="utf-8") + if path.suffix.lower().endswith("jsonl"): + events: List[Dict[str, Any]] = [] + for line in text.splitlines(): + ev = parse_json_line(line) + if ev is not None: + events.append(ev) + return compute_aggregate_from_events(events) + + return parse_kv_summary(text) + + +def format_delta(cur: float, prev: float, is_rate: bool = False) -> str: + d = cur - prev + if is_rate: + return f"{cur * 100:.2f}% ({d * 100:+.2f}pp)" + if prev != 0: + pct = (d / prev) * 100.0 + return f"{cur:.2f} ({d:+.2f}, {pct:+.1f}%)" + return f"{cur:.2f} ({d:+.2f})" + + +def print_comparison_section( + current: Dict[str, float], previous: Dict[str, float] +) -> None: + print() + print("comparison") + print( + f"avg_main_rps: {format_delta(current.get('avg_main_rps', 0.0), previous.get('avg_main_rps', 0.0))}" + ) + print( + f"ok_rate: {format_delta(current.get('ok_rate', 0.0), previous.get('ok_rate', 0.0), is_rate=True)}" + ) + print( + f"total: {format_delta(current.get('total', 0.0), previous.get('total', 0.0))}" + ) + print( + f"ok: {format_delta(current.get('ok', 0.0), previous.get('ok', 0.0))}" + ) + print( + f"connect_fail: {format_delta(current.get('connect_fail', 0.0), previous.get('connect_fail', 0.0))}" + ) + print( + f"http_fail: {format_delta(current.get('http_fail', 0.0), previous.get('http_fail', 0.0))}" + ) + print( + f"other_fail: {format_delta(current.get('other_fail', 0.0), previous.get('other_fail', 0.0))}" + ) + print() + + def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--with-proxy", action="store_true") @@ -271,6 +351,16 @@ def main() -> int: ap.add_argument( "--report-file", default=None, help="write JSONL report to file for diffing" ) + ap.add_argument( + "--compare", + default=None, + help="path to previous summary.txt or jsonl for comparison", + ) + ap.add_argument( + "--save-baseline", + default=None, + help="write current baseline summary (kv) to this path", + ) ap.add_argument("--verbose", action="store_true") args = ap.parse_args() @@ -292,7 +382,6 @@ def main() -> int: env.setdefault("RUST_LOG", "debug" if args.verbose else "info") procs: List[Proc] = [] - keep_data = bool(args.report_file) def cleanup(keep: bool) -> None: @@ -354,8 +443,7 @@ def cleanup(keep: bool) -> None: else: target_addr = mock_addr - # Run benchmarker. - # We stream runner stdout to capture JSONL and show progress live. + # Run benchmarker run_argv = [ netbench, "run", @@ -430,14 +518,21 @@ def cleanup(keep: bool) -> None: f"- run log: {run_log}\n" ) + # Always write stable summary to data dir run_summary.write_text( render_diff_friendly_summary_from_events(events), encoding="utf-8" ) + # Compute metrics for comparison and saving + current_metrics = compute_aggregate_from_events(events) + + if args.save_baseline: + write_kv_baseline(Path(args.save_baseline), current_metrics) + # Stop services cleanup(keep=keep_data) - # If user asked for a report file, copy raw JSONL and summary to requested location. + # Copy report artifacts if requested if args.report_file: report_path = Path(args.report_file) report_path.parent.mkdir(parents=True, exist_ok=True) @@ -453,6 +548,12 @@ def cleanup(keep: bool) -> None: print() print("diff friendly summary") print(summary_path.read_text(encoding="utf-8").strip()) + + # Optional compare output even in report mode + if args.compare: + previous_metrics = load_metrics_from_path(Path(args.compare)) + print_comparison_section(current_metrics, previous_metrics) + print() print("logs") print(str(mock_log)) @@ -507,6 +608,10 @@ def cleanup(keep: bool) -> None: ) print() + if args.compare: + previous_metrics = load_metrics_from_path(Path(args.compare)) + print_comparison_section(current_metrics, previous_metrics) + print("artifacts") print(f"data dir: {data_dir}") print(f"jsonl: {run_jsonl}") From ae31be32fb3ee92d900fd35278cb1e659eeb549d Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 29 Jan 2026 23:52:15 +0100 Subject: [PATCH 29/52] link from proxy.md --- docs/proxy.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/proxy.md b/docs/proxy.md index 2d3debc6..89f2faf6 100644 --- a/docs/proxy.md +++ b/docs/proxy.md @@ -13,6 +13,10 @@ Other proxy docs: - [./proxy/pac.md](./proxy/pac.md): learn more about Proxy Auto Configuration and how the safechain proxy project supports this flow. +Related docs: + +- [./netbench.md](./netbench.md): learn how to benchmark the proxy code of safechain + ## Quick Start ### Running the Proxy From 166d9c9bba14783fee6c5ad4d3468ccd9c13919d Mon Sep 17 00:00:00 2001 From: glendc Date: Thu, 29 Jan 2026 23:57:41 +0100 Subject: [PATCH 30/52] support har replay from orchestrator --- proxy_netbench/run.py | 48 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/proxy_netbench/run.py b/proxy_netbench/run.py index 654ce412..054620b4 100755 --- a/proxy_netbench/run.py +++ b/proxy_netbench/run.py @@ -347,6 +347,13 @@ def main() -> int: "--scenario", default="baseline", choices=["baseline", "latency-jitter", "flaky-upstream"], + help="ignored when --har is set", + ) + ap.add_argument("--har", default=None, help="replay requests from this HAR file") + ap.add_argument( + "--emulate", + action="store_true", + help="when replaying, also emulate recorded timings", ) ap.add_argument( "--report-file", default=None, help="write JSONL report to file for diffing" @@ -364,6 +371,12 @@ def main() -> int: ap.add_argument("--verbose", action="store_true") args = ap.parse_args() + har_path: Optional[Path] = ( + Path(args.har).expanduser().resolve() if args.har else None + ) + if har_path is not None and not har_path.exists(): + raise RuntimeError(f"har file not found: {har_path}") + run_build(verbose=args.verbose) netbench = netbench_path() @@ -398,18 +411,23 @@ def cleanup(keep: bool) -> None: atexit.register(lambda: cleanup(keep_data)) - # Start mock server, traces to file + # Start mock server eprint("mock: starting") mock_argv = [ netbench, "mock", - "--scenario", - args.scenario, "--data", str(data_dir), "--output", str(mock_log), ] + if har_path is not None: + # HAR mode: replay responses from the HAR + mock_argv += ["--replay", str(har_path)] + else: + # Normal mode: use scenario behavior + mock_argv += ["--scenario", args.scenario] + mock_proc = start_process("mock", mock_argv, env) procs.append(mock_proc) @@ -419,7 +437,7 @@ def cleanup(keep: bool) -> None: ensure_process_alive(mock_proc) eprint("mock:", mock_addr) - # Start proxy optionally, traces to file + # Start proxy optionally if args.with_proxy: eprint("proxy: starting") proxy_argv = [ @@ -448,15 +466,23 @@ def cleanup(keep: bool) -> None: netbench, "run", "--json", - "--scenario", - args.scenario, "--data", str(data_dir), "--output", str(run_log), ] + + if har_path is not None: + # HAR mode: runner replays requests + run_argv += ["--replay", str(har_path)] + if args.emulate: + run_argv.append("--emulate") + else: + run_argv += ["--scenario", args.scenario] + if args.with_proxy: run_argv.append("--proxy") + run_argv.append(target_addr) eprint("run: started") @@ -549,7 +575,6 @@ def cleanup(keep: bool) -> None: print("diff friendly summary") print(summary_path.read_text(encoding="utf-8").strip()) - # Optional compare output even in report mode if args.compare: previous_metrics = load_metrics_from_path(Path(args.compare)) print_comparison_section(current_metrics, previous_metrics) @@ -565,7 +590,14 @@ def cleanup(keep: bool) -> None: # Human output print() print("netbench finished") - print(f"scenario={args.scenario} proxied={'yes' if args.with_proxy else 'no'}") + if har_path is not None: + print( + f"mode=har har={har_path} emulate={'yes' if args.emulate else 'no'} proxied={'yes' if args.with_proxy else 'no'}" + ) + else: + print( + f"mode=scenario scenario={args.scenario} proxied={'yes' if args.with_proxy else 'no'}" + ) print() summaries = [e for e in events if e.get("type") == "summary"] From 583f9206efe5b18835f5ab55a2a771de6ac5bea5 Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 30 Jan 2026 00:07:48 +0100 Subject: [PATCH 31/52] support proxy-benchmarking from CI --- .github/workflows/proxy-benchmark.yml | 183 +++++++++++++++++++ proxy_netbench/scripts/build_report.py | 126 +++++++++++++ proxy_netbench/scripts/download_baselines.py | 112 ++++++++++++ proxy_netbench/scripts/post_pr_comment.py | 88 +++++++++ 4 files changed, 509 insertions(+) create mode 100644 .github/workflows/proxy-benchmark.yml create mode 100644 proxy_netbench/scripts/build_report.py create mode 100644 proxy_netbench/scripts/download_baselines.py create mode 100644 proxy_netbench/scripts/post_pr_comment.py diff --git a/.github/workflows/proxy-benchmark.yml b/.github/workflows/proxy-benchmark.yml new file mode 100644 index 00000000..701761c1 --- /dev/null +++ b/.github/workflows/proxy-benchmark.yml @@ -0,0 +1,183 @@ +name: proxy benchmark + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: proxy-benchmark-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + NETBENCH_ORCHESTRATOR: netbench_orchestrator.py + BASELINE_ARTIFACT_NAME: netbench-baselines + BASELINE_DIR: baselines + RUN_DIR: run-metrics + +jobs: + bench: + name: bench (${{ matrix.scenario }}, proxy=${{ matrix.proxy }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + scenario: [baseline, latency-jitter, flaky-upstream] + proxy: [false, true] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + + - name: Download baselines from latest successful main run + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + mkdir -p "$BASELINE_DIR" + python3 proxy_netbench/scripts/download_baselines.py \ + --repo "${{ github.repository }}" \ + --workflow "proxy-benchmark.yml" \ + --artifact "$BASELINE_ARTIFACT_NAME" \ + --out "$BASELINE_DIR" \ + --branch "main" + + - name: Run netbench (matrix) + env: + SCENARIO: ${{ matrix.scenario }} + PROXY: ${{ matrix.proxy }} + run: | + set -euo pipefail + mkdir -p reports "$RUN_DIR" + + PROXY_FLAG="" + PROXY_LABEL="direct" + if [ "$PROXY" = "true" ]; then + PROXY_FLAG="--with-proxy" + PROXY_LABEL="proxy" + fi + + CUR_KV="$RUN_DIR/${SCENARIO}.${PROXY_LABEL}.kv.txt" + BASE_KV="$BASELINE_DIR/${SCENARIO}.${PROXY_LABEL}.kv.txt" + REPORT_PATH="reports/${SCENARIO}.${PROXY_LABEL}.jsonl" + + COMPARE_FLAG="" + if [ -f "$BASE_KV" ]; then + COMPARE_FLAG="--compare $BASE_KV" + fi + + python3 "$NETBENCH_ORCHESTRATOR" \ + $PROXY_FLAG \ + --scenario "$SCENARIO" \ + --report-file "$REPORT_PATH" \ + --save-baseline "$CUR_KV" \ + $COMPARE_FLAG + + - name: Upload run artifacts (jsonl, summary, kv) + uses: actions/upload-artifact@v4 + with: + name: netbench-${{ matrix.scenario }}-proxy-${{ matrix.proxy }} + path: | + reports/${{ matrix.scenario }}.${{ matrix.proxy && 'proxy' || 'direct' }}.jsonl + reports/${{ matrix.scenario }}.${{ matrix.proxy && 'proxy' || 'direct' }}.jsonl.summary.txt + run-metrics/${{ matrix.scenario }}.${{ matrix.proxy && 'proxy' || 'direct' }}.kv.txt + if-no-files-found: error + + publish_baselines: + name: publish baselines + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [bench] + runs-on: ubuntu-latest + steps: + - name: Download all matrix artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Collect baseline kv files + run: | + set -euo pipefail + mkdir -p baselines + find artifacts -type f -name "*.kv.txt" -print -exec cp {} baselines/ \; + ls -la baselines + + - name: Upload baselines artifact + uses: actions/upload-artifact@v4 + with: + name: netbench-baselines + path: baselines + if-no-files-found: error + + pr_comment: + name: report to PR + if: github.event_name == 'pull_request' + needs: [bench] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Download all matrix artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Download baselines from latest successful main run + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + mkdir -p baselines + python3 proxy_netbench/scripts/download_baselines.py \ + --repo "${{ github.repository }}" \ + --workflow "proxy-benchmark.yml" \ + --artifact "$BASELINE_ARTIFACT_NAME" \ + --out "baselines" \ + --branch "main" + + - name: Build markdown report + run: | + set -euo pipefail + python3 proxy_netbench/scripts/build_report.py \ + --artifacts-dir "artifacts" \ + --baselines-dir "baselines" \ + --out "report.md" \ + --sha "${{ github.sha }}" + + - name: Post or update PR comment + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + python3 proxy_netbench/scripts/post_pr_comment.py \ + --repo "${{ github.repository }}" \ + --pr "${{ github.event.pull_request.number }}" \ + --report "report.md" diff --git a/proxy_netbench/scripts/build_report.py b/proxy_netbench/scripts/build_report.py new file mode 100644 index 00000000..6ec22112 --- /dev/null +++ b/proxy_netbench/scripts/build_report.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# +from __future__ import annotations + +import argparse +import glob +import os +from pathlib import Path + + +def parse_kv(path: Path) -> dict[str, float]: + d: dict[str, float] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or "=" not in line: + continue + k, v = line.split("=", 1) + try: + d[k.strip()] = float(v.strip()) + except ValueError: + continue + return d + + +def fmt_delta(cur: float, prev: float, rate: bool = False) -> str: + d = cur - prev + if rate: + return f"{cur * 100:.2f}% ({d * 100:+.2f}pp)" + if prev != 0: + pct = (d / prev) * 100.0 + return f"{cur:.2f} ({d:+.2f}, {pct:+.1f}%)" + return f"{cur:.2f} ({d:+.2f})" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--artifacts-dir", default="artifacts") + ap.add_argument("--baselines-dir", default="baselines") + ap.add_argument("--out", default="report.md") + ap.add_argument("--sha", default=os.environ.get("GITHUB_SHA", "")[:12]) + args = ap.parse_args() + + artifacts_dir = Path(args.artifacts_dir) + baselines_dir = Path(args.baselines_dir) + out_path = Path(args.out) + + kv_paths = glob.glob(str(artifacts_dir / "**" / "*.kv.txt"), recursive=True) + kv_paths = sorted({p for p in kv_paths}) + + rows: list[tuple[str, dict[str, float], dict[str, float] | None]] = [] + for p in kv_paths: + cur_path = Path(p) + name = cur_path.name # scenario.proxylabel.kv.txt + cur = parse_kv(cur_path) + + base_path = baselines_dir / name + prev = parse_kv(base_path) if base_path.exists() else None + + parts = name.split(".") + scenario = parts[0] if len(parts) > 0 else name + proxylabel = parts[1] if len(parts) > 1 else "unknown" + + label = f"{scenario} / {proxylabel}" + rows.append((label, cur, prev)) + + lines: list[str] = [] + lines.append("") + lines.append("## netbench benchmark report") + lines.append("") + if args.sha: + lines.append(f"Commit: `{args.sha}`") + lines.append("") + + if not rows: + lines.append("No benchmark outputs found.") + out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return 0 + + lines.append( + "| scenario | avg main rps | ok rate | connect fail | http fail | other fail |" + ) + lines.append("|---|---:|---:|---:|---:|---:|") + + missing: list[str] = [] + + for label, cur, prev in rows: + avg = float(cur.get("avg_main_rps", 0.0)) + okr = float(cur.get("ok_rate", 0.0)) + cf = float(cur.get("connect_fail", 0.0)) + hf = float(cur.get("http_fail", 0.0)) + of = float(cur.get("other_fail", 0.0)) + + if prev is None: + missing.append(label) + lines.append( + f"| {label} | {avg:.2f} | {okr * 100:.2f}% | {cf:.0f} | {hf:.0f} | {of:.0f} |" + ) + else: + lines.append( + "| {label} | {avg} | {okr} | {cf} | {hf} | {of} |".format( + label=label, + avg=fmt_delta(avg, float(prev.get("avg_main_rps", 0.0))), + okr=fmt_delta(okr, float(prev.get("ok_rate", 0.0)), rate=True), + cf=fmt_delta(cf, float(prev.get("connect_fail", 0.0))), + hf=fmt_delta(hf, float(prev.get("http_fail", 0.0))), + of=fmt_delta(of, float(prev.get("other_fail", 0.0))), + ) + ) + + if missing: + lines.append("") + lines.append("Baselines missing for:") + for m in missing: + lines.append(f"- {m}") + lines.append("") + lines.append( + "Once a run lands on main, baselines will be published and PR comparisons will start working automatically." + ) + + out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(out_path.read_text(encoding="utf-8")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/proxy_netbench/scripts/download_baselines.py b/proxy_netbench/scripts/download_baselines.py new file mode 100644 index 00000000..ceae2dc9 --- /dev/null +++ b/proxy_netbench/scripts/download_baselines.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.parse +import urllib.request +import zipfile +from pathlib import Path + + +def eprint(*args: object) -> None: + print(*args, file=sys.stderr) + + +def api_get(url: str, token: str) -> dict: + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "User-Agent": "netbench-baseline-downloader", + }, + method="GET", + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def api_download(url: str, token: str, dest: Path) -> None: + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "User-Agent": "netbench-baseline-downloader", + }, + method="GET", + ) + with urllib.request.urlopen(req) as resp, dest.open("wb") as f: + while True: + chunk = resp.read(1024 * 128) + if not chunk: + break + f.write(chunk) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--repo", required=True, help="owner/repo") + ap.add_argument( + "--workflow", required=True, help="workflow file name, e.g. proxy-benchmark.yml" + ) + ap.add_argument( + "--artifact", required=True, help="artifact name, e.g. netbench-baselines" + ) + ap.add_argument("--out", required=True, help="output directory") + ap.add_argument("--branch", default="main") + args = ap.parse_args() + + token = os.environ.get("GITHUB_TOKEN") + if not token: + raise SystemExit("GITHUB_TOKEN is required") + + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + + base = "https://api.github.com" + owner, repo = args.repo.split("/", 1) + + runs_url = f"{base}/repos/{owner}/{repo}/actions/workflows/{urllib.parse.quote(args.workflow)}/runs?branch={urllib.parse.quote(args.branch)}&status=success&per_page=1" + runs = api_get(runs_url, token) + runs_list = runs.get("workflow_runs", []) + if not runs_list: + eprint("No successful workflow runs found for branch", args.branch) + return 0 + + run_id = runs_list[0]["id"] + + arts_url = f"{base}/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts" + arts = api_get(arts_url, token).get("artifacts", []) + hit = None + for a in arts: + if a.get("name") == args.artifact: + hit = a + break + + if not hit: + eprint("Baseline artifact not found in run", run_id) + return 0 + + art_id = hit["id"] + zip_url = f"{base}/repos/{owner}/{repo}/actions/artifacts/{art_id}/zip" + zip_path = out_dir / "baselines.zip" + + eprint("Downloading baseline artifact", args.artifact, "from run", run_id) + api_download(zip_url, token, zip_path) + + with zipfile.ZipFile(zip_path, "r") as z: + z.extractall(out_dir) + + zip_path.unlink(missing_ok=True) + + eprint("Baselines extracted into", str(out_dir)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/proxy_netbench/scripts/post_pr_comment.py b/proxy_netbench/scripts/post_pr_comment.py new file mode 100644 index 00000000..27cbb851 --- /dev/null +++ b/proxy_netbench/scripts/post_pr_comment.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.request +from pathlib import Path + + +def eprint(*args: object) -> None: + print(*args, file=sys.stderr) + + +def api_request( + url: str, token: str, method: str = "GET", body: dict | None = None +) -> dict: + data = None + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "User-Agent": "netbench-pr-commenter", + } + if body is not None: + raw = json.dumps(body).encode("utf-8") + data = raw + headers["Content-Type"] = "application/json" + + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req) as resp: + txt = resp.read().decode("utf-8") + return json.loads(txt) if txt else {} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--repo", required=True, help="owner/repo") + ap.add_argument("--pr", required=True, type=int, help="pull request number") + ap.add_argument("--report", default="report.md") + ap.add_argument("--marker", default="") + args = ap.parse_args() + + token = os.environ.get("GITHUB_TOKEN") + if not token: + raise SystemExit("GITHUB_TOKEN is required") + + report_path = Path(args.report) + body = report_path.read_text(encoding="utf-8") + marker = args.marker + + owner, repo = args.repo.split("/", 1) + base = "https://api.github.com" + list_url = f"{base}/repos/{owner}/{repo}/issues/{args.pr}/comments" + + comments = [] + page = 1 + while True: + url = f"{list_url}?per_page=100&page={page}" + data = api_request(url, token, "GET") + if not isinstance(data, list): + break + comments.extend(data) + if len(data) < 100: + break + page += 1 + + existing = None + for c in comments: + if marker in (c.get("body") or ""): + existing = c + break + + if existing: + cid = existing["id"] + update_url = f"{base}/repos/{owner}/{repo}/issues/comments/{cid}" + api_request(update_url, token, "PATCH", {"body": body}) + eprint("Updated existing netbench PR comment", cid) + else: + api_request(list_url, token, "POST", {"body": body}) + eprint("Created new netbench PR comment") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 753e0d44906ef6ba179f77b438d0669e075aab55 Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 30 Jan 2026 00:09:40 +0100 Subject: [PATCH 32/52] fix netbench proxy run script path in yml file --- .github/workflows/proxy-benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/proxy-benchmark.yml b/.github/workflows/proxy-benchmark.yml index 701761c1..b461b33c 100644 --- a/.github/workflows/proxy-benchmark.yml +++ b/.github/workflows/proxy-benchmark.yml @@ -15,7 +15,7 @@ concurrency: cancel-in-progress: true env: - NETBENCH_ORCHESTRATOR: netbench_orchestrator.py + NETBENCH_ORCHESTRATOR: proxy_netbench/run.py BASELINE_ARTIFACT_NAME: netbench-baselines BASELINE_DIR: baselines RUN_DIR: run-metrics From 344484a4e2b17c2133e4a088764fbdc1b759a9cc Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 30 Jan 2026 00:28:22 +0100 Subject: [PATCH 33/52] attempt to fix reporting --- proxy_netbench/run.py | 128 +++++++++++++++++++++++++++++++++--------- 1 file changed, 103 insertions(+), 25 deletions(-) diff --git a/proxy_netbench/run.py b/proxy_netbench/run.py index 054620b4..21ca854f 100755 --- a/proxy_netbench/run.py +++ b/proxy_netbench/run.py @@ -140,24 +140,79 @@ def safe_get(d: Dict[str, Any], *path: str, default: Any = None) -> Any: def render_diff_friendly_summary_from_events(events: List[Dict[str, Any]]) -> str: - finals = [e for e in events if e.get("type") == "final"] - final = finals[-1] if finals else None - if not final: - return "no_final_event=1\n" - - total = safe_get(final, "total", default={}) - total_total = int(safe_get(total, "total", default=0)) - total_ok = int(safe_get(total, "ok", default=0)) - total_conn = int(safe_get(total, "connect_fail", default=0)) - total_http = int(safe_get(total, "http_fail", default=0)) - total_other = int(safe_get(total, "other_fail", default=0)) + def read_totals_from_container(container: Any) -> Optional[Dict[str, int]]: + if not isinstance(container, dict): + return None + + # Most common: {"total": {...}} + t = container.get("total") + if isinstance(t, dict): + return { + "total": int(t.get("total", 0)), + "ok": int(t.get("ok", 0)), + "connect_fail": int(t.get("connect_fail", 0)), + "http_fail": int(t.get("http_fail", 0)), + "other_fail": int(t.get("other_fail", 0)), + } + + # Alternate: totals are flat on the event itself + if "total" in container or "ok" in container: + return { + "total": int(container.get("total", 0)), + "ok": int(container.get("ok", 0)), + "connect_fail": int(container.get("connect_fail", 0)), + "http_fail": int(container.get("http_fail", 0)), + "other_fail": int(container.get("other_fail", 0)), + } + + return None + + # 1) Prefer final event totals + final = next((e for e in reversed(events) if e.get("type") == "final"), None) + totals = read_totals_from_container(final) + + # 2) Fallback to last summary totals + if totals is None: + last_summary = next( + (e for e in reversed(events) if e.get("type") == "summary"), None + ) + totals = read_totals_from_container(last_summary) + + # 3) Last resort: sum interval counters across all summaries + if totals is None: + total_total = 0 + total_ok = 0 + total_conn = 0 + total_http = 0 + total_other = 0 + + for e in events: + if e.get("type") != "summary": + continue + interval = e.get("interval") + if not isinstance(interval, dict): + continue + + total_total += int(interval.get("total", 0)) + total_ok += int(interval.get("ok", 0)) + total_conn += int(interval.get("connect_fail", 0)) + total_http += int(interval.get("http_fail", 0)) + total_other += int(interval.get("other_fail", 0)) + + totals = { + "total": total_total, + "ok": total_ok, + "connect_fail": total_conn, + "http_fail": total_http, + "other_fail": total_other, + } lines = [] - lines.append(f"total={total_total}") - lines.append(f"ok={total_ok}") - lines.append(f"connect_fail={total_conn}") - lines.append(f"http_fail={total_http}") - lines.append(f"other_fail={total_other}") + lines.append(f"total={totals['total']}") + lines.append(f"ok={totals['ok']}") + lines.append(f"connect_fail={totals['connect_fail']}") + lines.append(f"http_fail={totals['http_fail']}") + lines.append(f"other_fail={totals['other_fail']}") return "\n".join(lines) + "\n" @@ -229,20 +284,43 @@ def print_progress_line(s: Dict[str, Any], start_ts: float) -> None: def compute_aggregate_from_events(events: List[Dict[str, Any]]) -> Dict[str, float]: final = next((e for e in reversed(events) if e.get("type") == "final"), None) - total_total = float(safe_get(final or {}, "total", "total", default=0)) - total_ok = float(safe_get(final or {}, "total", "ok", default=0)) - connect_fail = float(safe_get(final or {}, "total", "connect_fail", default=0)) - http_fail = float(safe_get(final or {}, "total", "http_fail", default=0)) - other_fail = float(safe_get(final or {}, "total", "other_fail", default=0)) + totals_src: Optional[Dict[str, Any]] = None + + if isinstance(final, dict): + t = final.get("total") + if isinstance(t, dict) and ("total" in t or "ok" in t): + totals_src = t + + if totals_src is None: + last_summary = next( + (e for e in reversed(events) if e.get("type") == "summary"), None + ) + if isinstance(last_summary, dict): + t = last_summary.get("total") + if isinstance(t, dict) and ("total" in t or "ok" in t): + totals_src = t + + if totals_src is None: + total_total = 0.0 + total_ok = 0.0 + connect_fail = 0.0 + http_fail = 0.0 + other_fail = 0.0 + else: + total_total = float(totals_src.get("total", 0.0)) + total_ok = float(totals_src.get("ok", 0.0)) + connect_fail = float(totals_src.get("connect_fail", 0.0)) + http_fail = float(totals_src.get("http_fail", 0.0)) + other_fail = float(totals_src.get("other_fail", 0.0)) ok_rate = (total_ok / total_total) if total_total > 0 else 0.0 - summaries = [ + main_summaries = [ e for e in events if e.get("type") == "summary" and e.get("phase") == "main" ] - if summaries: - avg_rps = sum(float(s.get("rps", 0.0)) for s in summaries) / float( - len(summaries) + if main_summaries: + avg_rps = sum(float(s.get("rps", 0.0)) for s in main_summaries) / float( + len(main_summaries) ) else: avg_rps = 0.0 From c83ad180ca4099856da7584bac18b9af0ffcaf13 Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 30 Jan 2026 00:40:12 +0100 Subject: [PATCH 34/52] delete old connect-fail error (no longer a thing since last refactor) --- docs/netbench.md | 1 - proxy_netbench/run.py | 39 +++++++------------------- proxy_netbench/scripts/build_report.py | 12 +++----- 3 files changed, 14 insertions(+), 38 deletions(-) diff --git a/docs/netbench.md b/docs/netbench.md index 78909e2a..bccf059b 100644 --- a/docs/netbench.md +++ b/docs/netbench.md @@ -180,7 +180,6 @@ avg_main_rps: 835.40 (+12.30, +1.5%) ok_rate: 76.10% (-0.80pp) total: 120000.00 (+0.00) ok: 91320.00 (-960.00, -1.0%) -connect_fail: 0.00 (+0.00) http_fail: 28680.00 (+960.00, +3.5%) ``` diff --git a/proxy_netbench/run.py b/proxy_netbench/run.py index 21ca854f..d35ba3b5 100755 --- a/proxy_netbench/run.py +++ b/proxy_netbench/run.py @@ -150,7 +150,6 @@ def read_totals_from_container(container: Any) -> Optional[Dict[str, int]]: return { "total": int(t.get("total", 0)), "ok": int(t.get("ok", 0)), - "connect_fail": int(t.get("connect_fail", 0)), "http_fail": int(t.get("http_fail", 0)), "other_fail": int(t.get("other_fail", 0)), } @@ -160,7 +159,6 @@ def read_totals_from_container(container: Any) -> Optional[Dict[str, int]]: return { "total": int(container.get("total", 0)), "ok": int(container.get("ok", 0)), - "connect_fail": int(container.get("connect_fail", 0)), "http_fail": int(container.get("http_fail", 0)), "other_fail": int(container.get("other_fail", 0)), } @@ -182,7 +180,6 @@ def read_totals_from_container(container: Any) -> Optional[Dict[str, int]]: if totals is None: total_total = 0 total_ok = 0 - total_conn = 0 total_http = 0 total_other = 0 @@ -195,14 +192,12 @@ def read_totals_from_container(container: Any) -> Optional[Dict[str, int]]: total_total += int(interval.get("total", 0)) total_ok += int(interval.get("ok", 0)) - total_conn += int(interval.get("connect_fail", 0)) total_http += int(interval.get("http_fail", 0)) total_other += int(interval.get("other_fail", 0)) totals = { "total": total_total, "ok": total_ok, - "connect_fail": total_conn, "http_fail": total_http, "other_fail": total_other, } @@ -210,7 +205,6 @@ def read_totals_from_container(container: Any) -> Optional[Dict[str, int]]: lines = [] lines.append(f"total={totals['total']}") lines.append(f"ok={totals['ok']}") - lines.append(f"connect_fail={totals['connect_fail']}") lines.append(f"http_fail={totals['http_fail']}") lines.append(f"other_fail={totals['other_fail']}") return "\n".join(lines) + "\n" @@ -237,11 +231,8 @@ def print_runner_progress_spinner( phase = str(last_summary.get("phase", "")) rps = float(last_summary.get("rps", 0.0)) ok = int(safe_get(last_summary, "interval", "ok", default=0)) - cf = int(safe_get(last_summary, "interval", "connect_fail", default=0)) hf = int(safe_get(last_summary, "interval", "http_fail", default=0)) - msg = ( - f"{ch} {elapsed:6.1f}s phase={phase} rps={rps:6.1f} ok={ok} cf={cf} hf={hf}" - ) + msg = f"{ch} {elapsed:6.1f}s phase={phase} rps={rps:6.1f} ok={ok} hf={hf}" else: msg = f"{ch} {elapsed:6.1f}s running" @@ -259,7 +250,6 @@ def format_summary_line(s: Dict[str, Any], elapsed_wall_s: float) -> str: phase = str(s.get("phase", "")) rps = float(s.get("rps", 0.0)) ok = int(safe_get(s, "interval", "ok", default=0)) - cf = int(safe_get(s, "interval", "connect_fail", default=0)) hf = int(safe_get(s, "interval", "http_fail", default=0)) tot_ok = int(safe_get(s, "total", "ok", default=0)) @@ -271,7 +261,7 @@ def format_summary_line(s: Dict[str, Any], elapsed_wall_s: float) -> str: return ( f"[{t_s:6.1f}s] phase={phase:6s} " - f"rps={rps:7.1f} ok={ok:5d} cf={cf:4d} hf={hf:4d} " + f"rps={rps:7.1f} ok={ok:5d} hf={hf:4d} " f"total_ok={tot_ok} total_fail={tot_fail}" ) @@ -303,13 +293,11 @@ def compute_aggregate_from_events(events: List[Dict[str, Any]]) -> Dict[str, flo if totals_src is None: total_total = 0.0 total_ok = 0.0 - connect_fail = 0.0 http_fail = 0.0 other_fail = 0.0 else: total_total = float(totals_src.get("total", 0.0)) total_ok = float(totals_src.get("ok", 0.0)) - connect_fail = float(totals_src.get("connect_fail", 0.0)) http_fail = float(totals_src.get("http_fail", 0.0)) other_fail = float(totals_src.get("other_fail", 0.0)) @@ -329,7 +317,6 @@ def compute_aggregate_from_events(events: List[Dict[str, Any]]) -> Dict[str, flo "avg_main_rps": avg_rps, "total": total_total, "ok": total_ok, - "connect_fail": connect_fail, "http_fail": http_fail, "other_fail": other_fail, "ok_rate": ok_rate, @@ -342,7 +329,6 @@ def write_kv_baseline(path: Path, metrics: Dict[str, float]) -> None: f"avg_main_rps={metrics.get('avg_main_rps', 0.0)}", f"total={metrics.get('total', 0.0)}", f"ok={metrics.get('ok', 0.0)}", - f"connect_fail={metrics.get('connect_fail', 0.0)}", f"http_fail={metrics.get('http_fail', 0.0)}", f"other_fail={metrics.get('other_fail', 0.0)}", f"ok_rate={metrics.get('ok_rate', 0.0)}", @@ -406,9 +392,6 @@ def print_comparison_section( print( f"ok: {format_delta(current.get('ok', 0.0), previous.get('ok', 0.0))}" ) - print( - f"connect_fail: {format_delta(current.get('connect_fail', 0.0), previous.get('connect_fail', 0.0))}" - ) print( f"http_fail: {format_delta(current.get('http_fail', 0.0), previous.get('http_fail', 0.0))}" ) @@ -680,28 +663,27 @@ def cleanup(keep: bool) -> None: summaries = [e for e in events if e.get("type") == "summary"] if summaries: - rows: List[Tuple[float, str, float, int, int, int]] = [] + rows: List[Tuple[float, str, float, int, int]] = [] for s in summaries: t_ms = float(s.get("t_ms", 0.0)) t_s = t_ms / 1000.0 phase = str(s.get("phase", "")) rps = float(s.get("rps", 0.0)) ok = int(safe_get(s, "interval", "ok", default=0)) - cf = int(safe_get(s, "interval", "connect_fail", default=0)) hf = int(safe_get(s, "interval", "http_fail", default=0)) - rows.append((t_s, phase, rps, ok, cf, hf)) + rows.append((t_s, phase, rps, ok, hf)) print("per second summary (last 12)") - print("time_s phase rps ok connect_fail http_fail") - for t_s, phase, rps, ok, cf, hf in rows[-12:]: - print(f"{t_s:6.1f} {phase:6s} {rps:6.1f} {ok:5d} {cf:12d} {hf:9d}") + print("time_s phase rps ok http_fail") + for t_s, phase, rps, ok, hf in rows[-12:]: + print(f"{t_s:6.1f} {phase:6s} {rps:6.1f} {ok:5d} {hf:9d}") max_rps = max(r[2] for r in rows) if rows else 0.0 print() print("rps graph (last 24)") - for t_s, phase, rps, ok, cf, hf in rows[-24:]: + for t_s, phase, rps, ok, hf in rows[-24:]: bar = ascii_bar(rps, max_rps, width=24) - print(f"{t_s:6.1f} {phase:6s} {rps:6.1f} {bar} ok={ok} cf={cf} hf={hf}") + print(f"{t_s:6.1f} {phase:6s} {rps:6.1f} {bar} ok={ok} hf={hf}") print() final = next((e for e in reversed(events) if e.get("type") == "final"), None) @@ -709,12 +691,11 @@ def cleanup(keep: bool) -> None: total = safe_get(final, "total", default={}) total_total = int(safe_get(total, "total", default=0)) total_ok = int(safe_get(total, "ok", default=0)) - total_cf = int(safe_get(total, "connect_fail", default=0)) total_hf = int(safe_get(total, "http_fail", default=0)) total_of = int(safe_get(total, "other_fail", default=0)) print("final totals") print( - f"total={total_total} ok={total_ok} connect_fail={total_cf} http_fail={total_hf} other_fail={total_of}" + f"total={total_total} ok={total_ok} http_fail={total_hf} other_fail={total_of}" ) print() diff --git a/proxy_netbench/scripts/build_report.py b/proxy_netbench/scripts/build_report.py index 6ec22112..880e6b2d 100644 --- a/proxy_netbench/scripts/build_report.py +++ b/proxy_netbench/scripts/build_report.py @@ -76,32 +76,28 @@ def main() -> int: out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") return 0 - lines.append( - "| scenario | avg main rps | ok rate | connect fail | http fail | other fail |" - ) - lines.append("|---|---:|---:|---:|---:|---:|") + lines.append("| scenario | avg main rps | ok rate | http fail | other fail |") + lines.append("|---|---:|---:|---:|---:|") missing: list[str] = [] for label, cur, prev in rows: avg = float(cur.get("avg_main_rps", 0.0)) okr = float(cur.get("ok_rate", 0.0)) - cf = float(cur.get("connect_fail", 0.0)) hf = float(cur.get("http_fail", 0.0)) of = float(cur.get("other_fail", 0.0)) if prev is None: missing.append(label) lines.append( - f"| {label} | {avg:.2f} | {okr * 100:.2f}% | {cf:.0f} | {hf:.0f} | {of:.0f} |" + f"| {label} | {avg:.2f} | {okr * 100:.2f}% | {hf:.0f} | {of:.0f} |" ) else: lines.append( - "| {label} | {avg} | {okr} | {cf} | {hf} | {of} |".format( + "| {label} | {avg} | {okr} | {hf} | {of} |".format( label=label, avg=fmt_delta(avg, float(prev.get("avg_main_rps", 0.0))), okr=fmt_delta(okr, float(prev.get("ok_rate", 0.0)), rate=True), - cf=fmt_delta(cf, float(prev.get("connect_fail", 0.0))), hf=fmt_delta(hf, float(prev.get("http_fail", 0.0))), of=fmt_delta(of, float(prev.get("other_fail", 0.0))), ) From 60c08ab7f354c01ad7a469e089a750316b0a15f6 Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 30 Jan 2026 12:01:32 +0100 Subject: [PATCH 35/52] improve reporting / bug-fix e-traffic / et al --- .github/workflows/proxy-benchmark.yml | 103 ++++++++++++++++--- proxy/src/client/mod.rs | 37 +++++-- proxy_netbench/har_files/mini_toy.har.json | 1 + proxy_netbench/har_files/synthetic.har.json | 1 + proxy_netbench/run.py | 35 ++++--- proxy_netbench/src/cmd/mock/fake_reporter.rs | 47 +++++++++ proxy_netbench/src/cmd/mock/mod.rs | 10 +- proxy_netbench/src/cmd/proxy/mod.rs | 23 ++++- proxy_netbench/src/cmd/run/client.rs | 81 ++++++++++++--- proxy_netbench/src/cmd/run/mod.rs | 31 ++++-- proxy_netbench/src/config/scenario.rs | 18 ++-- proxy_netbench/src/definitions.rs | 5 + proxy_netbench/src/http/har.rs | 12 ++- proxy_netbench/src/main.rs | 1 + 14 files changed, 328 insertions(+), 77 deletions(-) create mode 100644 proxy_netbench/har_files/mini_toy.har.json create mode 100644 proxy_netbench/har_files/synthetic.har.json create mode 100644 proxy_netbench/src/cmd/mock/fake_reporter.rs create mode 100644 proxy_netbench/src/definitions.rs diff --git a/.github/workflows/proxy-benchmark.yml b/.github/workflows/proxy-benchmark.yml index b461b33c..67acdfe9 100644 --- a/.github/workflows/proxy-benchmark.yml +++ b/.github/workflows/proxy-benchmark.yml @@ -28,7 +28,7 @@ jobs: fail-fast: false matrix: scenario: [baseline, latency-jitter, flaky-upstream] - proxy: [false, true] + proxy: [none, global, scoped] steps: - name: Checkout @@ -73,16 +73,9 @@ jobs: set -euo pipefail mkdir -p reports "$RUN_DIR" - PROXY_FLAG="" - PROXY_LABEL="direct" - if [ "$PROXY" = "true" ]; then - PROXY_FLAG="--with-proxy" - PROXY_LABEL="proxy" - fi - - CUR_KV="$RUN_DIR/${SCENARIO}.${PROXY_LABEL}.kv.txt" - BASE_KV="$BASELINE_DIR/${SCENARIO}.${PROXY_LABEL}.kv.txt" - REPORT_PATH="reports/${SCENARIO}.${PROXY_LABEL}.jsonl" + CUR_KV="$RUN_DIR/${SCENARIO}.${PROXY}.kv.txt" + BASE_KV="$BASELINE_DIR/${SCENARIO}.${PROXY}.kv.txt" + REPORT_PATH="reports/${SCENARIO}.${PROXY}.jsonl" COMPARE_FLAG="" if [ -f "$BASE_KV" ]; then @@ -90,7 +83,7 @@ jobs: fi python3 "$NETBENCH_ORCHESTRATOR" \ - $PROXY_FLAG \ + --proxy "$PROXY" \ --scenario "$SCENARIO" \ --report-file "$REPORT_PATH" \ --save-baseline "$CUR_KV" \ @@ -101,9 +94,89 @@ jobs: with: name: netbench-${{ matrix.scenario }}-proxy-${{ matrix.proxy }} path: | - reports/${{ matrix.scenario }}.${{ matrix.proxy && 'proxy' || 'direct' }}.jsonl - reports/${{ matrix.scenario }}.${{ matrix.proxy && 'proxy' || 'direct' }}.jsonl.summary.txt - run-metrics/${{ matrix.scenario }}.${{ matrix.proxy && 'proxy' || 'direct' }}.kv.txt + reports/${{ matrix.scenario }}.${{ matrix.proxy }}.jsonl + reports/${{ matrix.scenario }}.${{ matrix.proxy }}.jsonl.summary.txt + run-metrics/${{ matrix.scenario }}.${{ matrix.proxy }}.kv.txt + if-no-files-found: error + + bench-replay: + name: bench replay (${{ matrix.name }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: synthetic + har_fp: proxy_netbench/har_files/synthetic.har.json + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + + - name: Download baselines from latest successful main run + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + mkdir -p "$BASELINE_DIR" + python3 proxy_netbench/scripts/download_baselines.py \ + --repo "${{ github.repository }}" \ + --workflow "proxy-benchmark.yml" \ + --artifact "$BASELINE_ARTIFACT_NAME" \ + --out "$BASELINE_DIR" \ + --branch "main" + + - name: Run netbench (matrix) + env: + REPLAY_NAME: ${{ matrix.name }} + REPLAY_HAR_FP: ${{ matrix.har_fp }} + run: | + set -euo pipefail + mkdir -p reports "$RUN_DIR" + + CUR_KV="$RUN_DIR/replay_${REPLAY_NAME}.global.kv.txt" + BASE_KV="$BASELINE_DIR/replay_${REPLAY_NAME}.global.kv.txt" + REPORT_PATH="reports/replay_${REPLAY_NAME}.global.jsonl" + + COMPARE_FLAG="" + if [ -f "$BASE_KV" ]; then + COMPARE_FLAG="--compare $BASE_KV" + fi + + python3 "$NETBENCH_ORCHESTRATOR" \ + --proxy "global" \ + --har "$REPLAY_HAR_FP" \ + --emulate \ + --report-file "$REPORT_PATH" \ + --save-baseline "$CUR_KV" \ + $COMPARE_FLAG + + - name: Upload run artifacts (jsonl, summary, kv) + uses: actions/upload-artifact@v4 + with: + name: netbench-replay_${{ matrix.name }}-proxy-global + path: | + reports/replay_${{ matrix.name }}.global.jsonl + reports/replay_${{ matrix.name }}.global.jsonl.summary.txt + run-metrics/replay_${{ matrix.name }}.global.kv.txt if-no-files-found: error publish_baselines: diff --git a/proxy/src/client/mod.rs b/proxy/src/client/mod.rs index ae816743..634e3bd9 100644 --- a/proxy/src/client/mod.rs +++ b/proxy/src/client/mod.rs @@ -15,13 +15,14 @@ use ::{ Service, error::{ErrorContext as _, OpaqueError}, http::{Request, Response, Version, client::EasyHttpWebClient}, - net::client::pool::http::HttpPooledConnectorConfig, rt::Executor, telemetry::tracing, }, std::time::Duration, }; +use {rama::net::client::pool::http::HttpPooledConnectorConfig, std::fmt}; + #[cfg(test)] mod mock_client; @@ -30,19 +31,32 @@ pub use self::mock_client::new_mock_client as new_web_client; pub mod transport; -#[derive(Debug, Default)] +#[derive(Default)] pub struct WebClientConfig { + pub pool_cfg: Option, #[cfg(all(not(test), feature = "bench"))] pub do_not_allow_overwrite: bool, } +impl fmt::Debug for WebClientConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WebClientConfig").finish() + } +} + impl WebClientConfig { pub fn without_overwrites() -> Self { Self { + pool_cfg: None, #[cfg(all(not(test), feature = "bench"))] do_not_allow_overwrite: true, } } + + pub fn with_pool_cfg(mut self, cfg: HttpPooledConnectorConfig) -> Self { + self.pool_cfg = Some(cfg); + self + } } /// Create a new web client that can be cloned and shared. @@ -53,8 +67,16 @@ pub fn new_web_client( ) -> Result + Clone, OpaqueError> { tracing::trace!("new_web_client w/ cfg: {cfg:?}"); - let max_active = crate::utils::env::compute_concurrent_request_count(); - let max_total = max_active * 2; + let pool_cfg = cfg.pool_cfg.unwrap_or_else(|| { + let max_active = crate::utils::env::compute_concurrent_request_count(); + let max_total = max_active * 2; + HttpPooledConnectorConfig { + max_total, + max_active, + wait_for_pool_timeout: Some(Duration::from_secs(120)), + idle_timeout: Some(Duration::from_secs(300)), + } + }); let tcp_connector = self::transport::new_tcp_connector( exec.clone(), @@ -73,12 +95,7 @@ pub fn new_web_client( // no protocol negotation happens on layers such as TLS (e.g. ALPN) .with_tls_support_using_rustls_and_default_http_version(Some(tls_config), Version::HTTP_11) .with_default_http_connector(Executor::default()) - .try_with_connection_pool(HttpPooledConnectorConfig { - max_total, - max_active, - wait_for_pool_timeout: Some(Duration::from_secs(120)), - idle_timeout: Some(Duration::from_secs(300)), - }) + .try_with_connection_pool(pool_cfg) .context("create connection pool for proxy web client")? .build_client()) } diff --git a/proxy_netbench/har_files/mini_toy.har.json b/proxy_netbench/har_files/mini_toy.har.json new file mode 100644 index 00000000..08616076 --- /dev/null +++ b/proxy_netbench/har_files/mini_toy.har.json @@ -0,0 +1 @@ +{"log":{"version":"1.2","creator":{"name":"WebInspector","version":"537.1"},"pages":[{"startedDateTime":"2012-08-28T05:14:24.803Z","id":"page_1","title":"http://www.igvita.com/","pageTimings":{"onContentLoad":299,"onLoad":301}}],"entries":[{"startedDateTime":"2012-08-28T05:14:24.803Z","time":121,"request":{"method":"GET","url":"http://www.igvita.com/","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"www.igvita.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},{"name":"Cache-Control","value":"max-age=0"}],"queryString":[],"cookies":[],"headersSize":678,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Tue, 28 Aug 2012 05:14:24 GMT"},{"name":"Via","value":"HTTP/1.1 GWA"},{"name":"Transfer-Encoding","value":"chunked"},{"name":"Content-Encoding","value":"gzip"},{"name":"X-XSS-Protection","value":"1; mode=block"},{"name":"X-UA-Compatible","value":"IE=Edge,chrome=1"},{"name":"X-Page-Speed","value":"50_1_cn"},{"name":"Server","value":"nginx/1.0.11"},{"name":"Vary","value":"Accept-Encoding"},{"name":"Content-Type","value":"text/html; charset=utf-8"},{"name":"Cache-Control","value":"max-age=0, no-cache"},{"name":"Expires","value":"Tue, 28 Aug 2012 05:14:24 GMT"}],"cookies":[],"content":{"size":9521,"mimeType":"text/html","compression":5896},"redirectURL":"","headersSize":379,"bodySize":3625},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":1,"wait":112,"receive":6,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.011Z","time":10,"request":{"method":"GET","url":"http://fonts.googleapis.com/css?family=Open+Sans:400,600","httpVersion":"HTTP/1.1","headers":[],"queryString":[{"name":"family","value":"Open+Sans:400,600"}],"cookies":[],"headersSize":71,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[],"cookies":[],"content":{"size":542,"mimeType":"text/css"},"redirectURL":"","headersSize":17,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":-1,"wait":-1,"receive":2,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.017Z","time":31,"request":{"method":"GET","url":"http://1-ps.googleusercontent.com/h/www.igvita.com/css/style.css.pagespeed.ce.LzjUDNB25e.css","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"If-Modified-Since","value":"Mon, 27 Aug 2012 15:28:34 GMT"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"1-ps.googleusercontent.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"text/css,*/*;q=0.1"},{"name":"Cache-Control","value":"max-age=0"},{"name":"If-None-Match","value":"W/0"},{"name":"Referer","value":"http://www.igvita.com/"}],"queryString":[],"cookies":[],"headersSize":539,"bodySize":0},"response":{"status":304,"statusText":"Not Modified","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Mon, 27 Aug 2012 06:01:49 GMT"},{"name":"Age","value":"83556"},{"name":"Server","value":"GFE/2.0"},{"name":"ETag","value":"W/0"},{"name":"Expires","value":"Tue, 27 Aug 2013 06:01:49 GMT"}],"cookies":[],"content":{"size":14679,"mimeType":"text/css"},"redirectURL":"","headersSize":146,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":1,"wait":24,"receive":2,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.021Z","time":30,"request":{"method":"GET","url":"http://1-ps.googleusercontent.com/h/www.igvita.com/js/libs/modernizr.84728.js.pagespeed.jm._DgXLhVY42.js","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"If-Modified-Since","value":"Sat, 25 Aug 2012 14:30:37 GMT"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"1-ps.googleusercontent.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"*/*"},{"name":"Cache-Control","value":"max-age=0"},{"name":"If-None-Match","value":"W/0"},{"name":"Referer","value":"http://www.igvita.com/"}],"queryString":[],"cookies":[],"headersSize":536,"bodySize":0},"response":{"status":304,"statusText":"Not Modified","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Sat, 25 Aug 2012 14:30:37 GMT"},{"name":"Age","value":"225828"},{"name":"Server","value":"GFE/2.0"},{"name":"ETag","value":"W/0"},{"name":"Expires","value":"Sun, 25 Aug 2013 14:30:37 GMT"}],"cookies":[],"content":{"size":11831,"mimeType":"text/javascript"},"redirectURL":"","headersSize":147,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":0,"send":1,"wait":27,"receive":1,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.103Z","time":0,"request":{"method":"GET","url":"http://www.google-analytics.com/ga.js","httpVersion":"HTTP/1.1","headers":[],"queryString":[],"cookies":[],"headersSize":52,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Mon, 27 Aug 2012 21:57:00 GMT"},{"name":"Content-Encoding","value":"gzip"},{"name":"X-Content-Type-Options","value":"nosniff, nosniff"},{"name":"Age","value":"23052"},{"name":"Last-Modified","value":"Thu, 16 Aug 2012 07:05:05 GMT"},{"name":"Server","value":"GFE/2.0"},{"name":"Vary","value":"Accept-Encoding"},{"name":"Content-Type","value":"text/javascript"},{"name":"Expires","value":"Tue, 28 Aug 2012 09:57:00 GMT"},{"name":"Cache-Control","value":"max-age=43200, public"},{"name":"Content-Length","value":"14804"}],"cookies":[],"content":{"size":36893,"mimeType":"text/javascript"},"redirectURL":"","headersSize":17,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":-1,"wait":-1,"receive":0,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.123Z","time":91,"request":{"method":"GET","url":"http://1-ps.googleusercontent.com/beacon?org=50_1_cn&ets=load:93&ifr=0&hft=32&url=http%3A%2F%2Fwww.igvita.com%2F","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"1-ps.googleusercontent.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"*/*"},{"name":"Referer","value":"http://www.igvita.com/"}],"queryString":[{"name":"org","value":"50_1_cn"},{"name":"ets","value":"load:93"},{"name":"ifr","value":"0"},{"name":"hft","value":"32"},{"name":"url","value":"http%3A%2F%2Fwww.igvita.com%2F"}],"cookies":[],"headersSize":448,"bodySize":0},"response":{"status":204,"statusText":"No Content","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Tue, 28 Aug 2012 05:14:25 GMT"},{"name":"Content-Length","value":"0"},{"name":"X-XSS-Protection","value":"1; mode=block"},{"name":"Server","value":"PagespeedRewriteProxy 0.1"},{"name":"Content-Type","value":"text/plain"},{"name":"Cache-Control","value":"no-cache"}],"cookies":[],"content":{"size":0,"mimeType":"text/plain","compression":0},"redirectURL":"","headersSize":202,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":0,"wait":70,"receive":7,"ssl":-1},"pageref":"page_1"}]}} diff --git a/proxy_netbench/har_files/synthetic.har.json b/proxy_netbench/har_files/synthetic.har.json new file mode 100644 index 00000000..3ae2c477 --- /dev/null +++ b/proxy_netbench/har_files/synthetic.har.json @@ -0,0 +1 @@ +{"log":{"version":"1.2","creator":{"name":"AikidoBot","version":"1.0"},"pages":[{"startedDateTime":"2026-01-30T09:32:34.510Z","id":"page_0","title":"Synthetic 3s Example Traffic","pageTimings":{"onContentLoad":-1,"onLoad":-1}}],"entries":[{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.513Z","time":29,"request":{"method":"GET","url":"https://example.dev/ebdh86lbxuj3","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":455,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":455},"cache":{},"timings":{"send":3,"wait":13,"receive":13}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.531Z","time":30,"request":{"method":"GET","url":"https://example.edu/ka3jy45jh3t9","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7330,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7330},"cache":{},"timings":{"send":3,"wait":20,"receive":7}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.540Z","time":72,"request":{"method":"GET","url":"https://httpbin.org/dh751dltps6z","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10981,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10981},"cache":{},"timings":{"send":6,"wait":62,"receive":4}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.552Z","time":97,"request":{"method":"GET","url":"https://httpbin.org/fano8k0ckx3s","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5633,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5633},"cache":{},"timings":{"send":5,"wait":70,"receive":22}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.564Z","time":94,"request":{"method":"GET","url":"https://httpbin.org/z5dg2fx8wlph","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11062,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11062},"cache":{},"timings":{"send":2,"wait":68,"receive":24}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.582Z","time":138,"request":{"method":"GET","url":"https://example.net/te7odkrgixtp","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8675,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8675},"cache":{},"timings":{"send":5,"wait":101,"receive":32}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.584Z","time":101,"request":{"method":"GET","url":"https://example.com/3qm4fthtf2bf","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10696,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10696},"cache":{},"timings":{"send":1,"wait":86,"receive":14}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.611Z","time":62,"request":{"method":"GET","url":"https://example.com/d0ycra0fhnha","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3095,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3095},"cache":{},"timings":{"send":4,"wait":27,"receive":31}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.615Z","time":115,"request":{"method":"GET","url":"https://example.dev/079jm5efke54","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1872,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1872},"cache":{},"timings":{"send":1,"wait":94,"receive":20}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.627Z","time":41,"request":{"method":"GET","url":"https://example.invalid/s9l3m3c8zku8","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7534,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7534},"cache":{},"timings":{"send":6,"wait":17,"receive":18}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.633Z","time":52,"request":{"method":"GET","url":"https://example.edu/rh4x20s8jgbj","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3636,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3636},"cache":{},"timings":{"send":7,"wait":38,"receive":7}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.639Z","time":31,"request":{"method":"GET","url":"https://example.dev/x3py7a37m3ae","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8983,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8983},"cache":{},"timings":{"send":2,"wait":19,"receive":10}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.640Z","time":73,"request":{"method":"GET","url":"https://example.org/qrr8q15di1p7","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":715,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":715},"cache":{},"timings":{"send":2,"wait":48,"receive":23}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.644Z","time":71,"request":{"method":"GET","url":"https://example.com/ed8ib12dbzy1","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11563,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11563},"cache":{},"timings":{"send":3,"wait":60,"receive":8}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.644Z","time":85,"request":{"method":"GET","url":"https://example.edu/2z526ynkyqf7","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3330,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3330},"cache":{},"timings":{"send":2,"wait":49,"receive":34}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.647Z","time":74,"request":{"method":"GET","url":"https://example.org/90uhgx8t0dfk","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":906,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":906},"cache":{},"timings":{"send":7,"wait":45,"receive":22}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.654Z","time":125,"request":{"method":"GET","url":"https://example.edu/kqidq1xk2qql","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10853,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10853},"cache":{},"timings":{"send":1,"wait":112,"receive":12}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.674Z","time":77,"request":{"method":"GET","url":"https://example.edu/orgb50dirdqd","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1239,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1239},"cache":{},"timings":{"send":1,"wait":43,"receive":33}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.683Z","time":23,"request":{"method":"GET","url":"https://example.net/4q0r8g8dg2i8","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4866,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4866},"cache":{},"timings":{"send":1,"wait":14,"receive":8}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.690Z","time":69,"request":{"method":"GET","url":"https://httpbin.org/osst0wsvu3hm","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1177,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1177},"cache":{},"timings":{"send":5,"wait":55,"receive":9}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.703Z","time":121,"request":{"method":"GET","url":"https://example.invalid/osanv6781yvt","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1542,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1542},"cache":{},"timings":{"send":4,"wait":105,"receive":12}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.703Z","time":118,"request":{"method":"GET","url":"https://example.net/klpwvchnyu8n","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10556,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10556},"cache":{},"timings":{"send":3,"wait":94,"receive":21}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.710Z","time":44,"request":{"method":"GET","url":"https://example.io/7cqj2dbmu1wz","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8708,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8708},"cache":{},"timings":{"send":4,"wait":21,"receive":19}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.712Z","time":29,"request":{"method":"GET","url":"https://iana.org/cmzdpd917plc","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8727,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8727},"cache":{},"timings":{"send":2,"wait":20,"receive":7}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.727Z","time":73,"request":{"method":"GET","url":"https://example.net/kj054ggzqrxe","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8946,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8946},"cache":{},"timings":{"send":6,"wait":51,"receive":16}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.737Z","time":135,"request":{"method":"GET","url":"https://example.test/c0vxk22i9c01","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9314,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9314},"cache":{},"timings":{"send":5,"wait":95,"receive":35}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.738Z","time":116,"request":{"method":"GET","url":"https://example.net/wvsevy5x39dt","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8193,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8193},"cache":{},"timings":{"send":7,"wait":93,"receive":16}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.740Z","time":93,"request":{"method":"GET","url":"https://example.io/i013f2md0unj","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5996,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5996},"cache":{},"timings":{"send":7,"wait":80,"receive":6}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.769Z","time":60,"request":{"method":"GET","url":"https://httpbin.org/3mhmcj4w2h3t","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4684,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4684},"cache":{},"timings":{"send":7,"wait":35,"receive":18}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.793Z","time":49,"request":{"method":"GET","url":"https://httpbin.org/iv9mkv9dqprd","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":655,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":655},"cache":{},"timings":{"send":3,"wait":37,"receive":9}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.794Z","time":124,"request":{"method":"GET","url":"https://example.io/lfdjbaa09c5w","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1761,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1761},"cache":{},"timings":{"send":6,"wait":89,"receive":29}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.798Z","time":124,"request":{"method":"GET","url":"https://example.dev/vaknoig8cwi0","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3294,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3294},"cache":{},"timings":{"send":5,"wait":85,"receive":34}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.826Z","time":105,"request":{"method":"GET","url":"https://example.net/1hwtaq3q7g85","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5075,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5075},"cache":{},"timings":{"send":8,"wait":67,"receive":30}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.826Z","time":36,"request":{"method":"GET","url":"https://httpbin.org/7wra4zsud87i","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10452,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10452},"cache":{},"timings":{"send":1,"wait":29,"receive":6}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.829Z","time":66,"request":{"method":"GET","url":"https://example.org/vdsiiabf7tip","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10227,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10227},"cache":{},"timings":{"send":6,"wait":56,"receive":4}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.840Z","time":111,"request":{"method":"GET","url":"https://example.org/bbguxwqqf9vr","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10978,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10978},"cache":{},"timings":{"send":7,"wait":89,"receive":15}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.852Z","time":39,"request":{"method":"GET","url":"https://example.io/dmelknr4a7j8","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8069,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8069},"cache":{},"timings":{"send":3,"wait":30,"receive":6}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.866Z","time":63,"request":{"method":"GET","url":"https://example.test/l9aszsni2mr2","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3813,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3813},"cache":{},"timings":{"send":5,"wait":46,"receive":12}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.888Z","time":100,"request":{"method":"GET","url":"https://example.org/4wfu5judcqr0","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10528,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10528},"cache":{},"timings":{"send":5,"wait":67,"receive":28}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.914Z","time":126,"request":{"method":"GET","url":"https://example.dev/9nfa0wrqacko","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1711,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1711},"cache":{},"timings":{"send":8,"wait":93,"receive":25}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.986Z","time":53,"request":{"method":"GET","url":"https://httpbin.org/9stqrmqs3j0p","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5135,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5135},"cache":{},"timings":{"send":7,"wait":32,"receive":14}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:34.989Z","time":93,"request":{"method":"GET","url":"https://example.io/vub8io4vtypx","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5086,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5086},"cache":{},"timings":{"send":6,"wait":79,"receive":8}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.003Z","time":68,"request":{"method":"GET","url":"https://example.com/b5xh45iarvtw","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7019,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7019},"cache":{},"timings":{"send":3,"wait":44,"receive":21}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.016Z","time":90,"request":{"method":"GET","url":"https://example.org/nl40imgtot7v","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3547,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3547},"cache":{},"timings":{"send":6,"wait":67,"receive":17}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.034Z","time":92,"request":{"method":"GET","url":"https://example.com/nsar3ao6ak7x","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3354,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3354},"cache":{},"timings":{"send":2,"wait":81,"receive":9}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.038Z","time":90,"request":{"method":"GET","url":"https://httpbin.org/ku7o4tdoqzv2","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":765,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":765},"cache":{},"timings":{"send":3,"wait":55,"receive":32}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.040Z","time":77,"request":{"method":"GET","url":"https://iana.org/owyn2x02fevq","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1250,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1250},"cache":{},"timings":{"send":4,"wait":67,"receive":6}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.046Z","time":72,"request":{"method":"GET","url":"https://example.io/q09ya26wi9hj","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5833,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5833},"cache":{},"timings":{"send":8,"wait":44,"receive":20}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.057Z","time":116,"request":{"method":"GET","url":"https://example.com/zqhvk96sx1pz","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9541,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9541},"cache":{},"timings":{"send":3,"wait":111,"receive":2}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.061Z","time":43,"request":{"method":"GET","url":"https://example.io/ygwprg8vr8fg","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":720,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":720},"cache":{},"timings":{"send":8,"wait":23,"receive":12}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.087Z","time":50,"request":{"method":"GET","url":"https://example.invalid/tohd8jupq3as","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1256,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1256},"cache":{},"timings":{"send":7,"wait":24,"receive":19}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.093Z","time":123,"request":{"method":"GET","url":"https://httpbin.org/w1zi33ead9w7","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11871,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11871},"cache":{},"timings":{"send":3,"wait":96,"receive":24}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.098Z","time":81,"request":{"method":"GET","url":"https://iana.org/doa8nkqywd9g","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6153,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6153},"cache":{},"timings":{"send":7,"wait":43,"receive":31}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.106Z","time":67,"request":{"method":"GET","url":"https://example.edu/dq3yd2tlvqyg","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7483,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7483},"cache":{},"timings":{"send":6,"wait":57,"receive":4}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.117Z","time":60,"request":{"method":"GET","url":"https://example.io/kscpss13qqf9","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7172,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7172},"cache":{},"timings":{"send":8,"wait":35,"receive":17}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.134Z","time":35,"request":{"method":"GET","url":"https://httpbin.org/fll45xng4mc8","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2813,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2813},"cache":{},"timings":{"send":7,"wait":17,"receive":11}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.135Z","time":96,"request":{"method":"GET","url":"https://example.test/4sd1v03xv40e","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9949,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9949},"cache":{},"timings":{"send":2,"wait":78,"receive":16}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.135Z","time":89,"request":{"method":"GET","url":"https://example.test/974r2792zyo0","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8637,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8637},"cache":{},"timings":{"send":1,"wait":71,"receive":17}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.137Z","time":106,"request":{"method":"GET","url":"https://example.io/ndxseg4me29w","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9425,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9425},"cache":{},"timings":{"send":1,"wait":94,"receive":11}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.152Z","time":73,"request":{"method":"GET","url":"https://httpbin.org/ff881vjn2ddc","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8604,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8604},"cache":{},"timings":{"send":7,"wait":60,"receive":6}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.170Z","time":111,"request":{"method":"GET","url":"https://example.net/cphcjuxcm79w","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3937,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3937},"cache":{},"timings":{"send":8,"wait":72,"receive":31}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.201Z","time":100,"request":{"method":"GET","url":"https://example.org/ukw3kv315tmt","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3343,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3343},"cache":{},"timings":{"send":7,"wait":77,"receive":16}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.223Z","time":38,"request":{"method":"GET","url":"https://httpbin.org/sju76ldpr32i","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6765,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6765},"cache":{},"timings":{"send":6,"wait":18,"receive":14}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.239Z","time":96,"request":{"method":"GET","url":"https://example.org/6kfk2jw2vrt5","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7934,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7934},"cache":{},"timings":{"send":4,"wait":86,"receive":6}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.249Z","time":122,"request":{"method":"GET","url":"https://example.dev/550uuwuomz6r","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11714,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11714},"cache":{},"timings":{"send":5,"wait":98,"receive":19}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.265Z","time":38,"request":{"method":"GET","url":"https://httpbin.org/yxr5zeac1nyc","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2634,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2634},"cache":{},"timings":{"send":2,"wait":34,"receive":2}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.266Z","time":63,"request":{"method":"GET","url":"https://example.edu/5yhezftcsjkj","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7746,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7746},"cache":{},"timings":{"send":5,"wait":51,"receive":7}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.306Z","time":123,"request":{"method":"GET","url":"https://iana.org/nmg4m2ip1awr","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2195,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2195},"cache":{},"timings":{"send":5,"wait":104,"receive":14}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.315Z","time":93,"request":{"method":"GET","url":"https://example.io/j8x5kl21tztr","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8385,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8385},"cache":{},"timings":{"send":1,"wait":65,"receive":27}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.343Z","time":130,"request":{"method":"GET","url":"https://example.io/qjg5ohk8ugp0","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11462,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11462},"cache":{},"timings":{"send":7,"wait":115,"receive":8}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.344Z","time":87,"request":{"method":"GET","url":"https://iana.org/2rz0dz50p551","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2864,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2864},"cache":{},"timings":{"send":1,"wait":84,"receive":2}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.352Z","time":156,"request":{"method":"GET","url":"https://example.io/67nn1xrk08yt","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4574,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4574},"cache":{},"timings":{"send":3,"wait":119,"receive":34}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.361Z","time":101,"request":{"method":"GET","url":"https://iana.org/97vq013rzaow","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1858,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1858},"cache":{},"timings":{"send":4,"wait":71,"receive":26}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.370Z","time":138,"request":{"method":"GET","url":"https://example.io/sycbquy3dkxv","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11557,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11557},"cache":{},"timings":{"send":1,"wait":107,"receive":30}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.375Z","time":67,"request":{"method":"GET","url":"https://example.test/7829bn6e0w4a","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5011,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5011},"cache":{},"timings":{"send":2,"wait":52,"receive":13}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.385Z","time":131,"request":{"method":"GET","url":"https://example.io/mi6jo8dvbgci","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":549,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":549},"cache":{},"timings":{"send":1,"wait":102,"receive":28}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.388Z","time":100,"request":{"method":"GET","url":"https://example.net/tsc6kx0gnfie","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3122,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3122},"cache":{},"timings":{"send":3,"wait":72,"receive":25}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.435Z","time":86,"request":{"method":"GET","url":"https://example.dev/zkfr4pniprj2","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3660,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3660},"cache":{},"timings":{"send":8,"wait":56,"receive":22}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.436Z","time":91,"request":{"method":"GET","url":"https://example.edu/e73j13gocva7","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9272,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9272},"cache":{},"timings":{"send":1,"wait":79,"receive":11}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.462Z","time":32,"request":{"method":"GET","url":"https://example.dev/og4i55bpav9w","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9922,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9922},"cache":{},"timings":{"send":3,"wait":22,"receive":7}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.491Z","time":111,"request":{"method":"GET","url":"https://httpbin.org/qipdj5y98lxo","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4017,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4017},"cache":{},"timings":{"send":4,"wait":73,"receive":34}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.511Z","time":91,"request":{"method":"GET","url":"https://example.io/d2lbk35xl6of","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2949,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2949},"cache":{},"timings":{"send":6,"wait":57,"receive":28}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.512Z","time":97,"request":{"method":"GET","url":"https://example.net/9i6fs6ivvw3u","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8709,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8709},"cache":{},"timings":{"send":8,"wait":78,"receive":11}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.517Z","time":118,"request":{"method":"GET","url":"https://example.invalid/fmxhpmxbybsg","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2436,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2436},"cache":{},"timings":{"send":1,"wait":92,"receive":25}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.532Z","time":45,"request":{"method":"GET","url":"https://example.net/u5jepzhrf7k7","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1258,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1258},"cache":{},"timings":{"send":1,"wait":35,"receive":9}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.539Z","time":46,"request":{"method":"GET","url":"https://example.org/hg7vocvqa4l6","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10250,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10250},"cache":{},"timings":{"send":4,"wait":35,"receive":7}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.571Z","time":89,"request":{"method":"GET","url":"https://example.edu/pz2y00hmg9n0","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5188,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5188},"cache":{},"timings":{"send":6,"wait":62,"receive":21}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.571Z","time":74,"request":{"method":"GET","url":"https://example.edu/8f151tnn8uhq","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11113,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11113},"cache":{},"timings":{"send":4,"wait":62,"receive":8}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.589Z","time":131,"request":{"method":"GET","url":"https://example.dev/5c50kkj0cwqw","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3894,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3894},"cache":{},"timings":{"send":7,"wait":99,"receive":25}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.599Z","time":138,"request":{"method":"GET","url":"https://example.org/faa1lmtesiky","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5710,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5710},"cache":{},"timings":{"send":1,"wait":114,"receive":23}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.600Z","time":50,"request":{"method":"GET","url":"https://example.com/ipo3qqnhzeot","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5081,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5081},"cache":{},"timings":{"send":7,"wait":25,"receive":18}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.607Z","time":124,"request":{"method":"GET","url":"https://example.edu/qy2llhbkabhh","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7684,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7684},"cache":{},"timings":{"send":8,"wait":98,"receive":18}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.610Z","time":100,"request":{"method":"GET","url":"https://example.org/na4anpmk9afg","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11072,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11072},"cache":{},"timings":{"send":5,"wait":70,"receive":25}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.620Z","time":134,"request":{"method":"GET","url":"https://iana.org/y2543pvhvfy1","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1147,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1147},"cache":{},"timings":{"send":8,"wait":96,"receive":30}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.626Z","time":66,"request":{"method":"GET","url":"https://example.invalid/0o17gth9sacv","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3393,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3393},"cache":{},"timings":{"send":2,"wait":32,"receive":32}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.669Z","time":46,"request":{"method":"GET","url":"https://example.test/juqtddwdp6px","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":668,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":668},"cache":{},"timings":{"send":6,"wait":16,"receive":24}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.681Z","time":141,"request":{"method":"GET","url":"https://httpbin.org/r1ghhm3scabw","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8838,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8838},"cache":{},"timings":{"send":2,"wait":104,"receive":35}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.683Z","time":69,"request":{"method":"GET","url":"https://example.dev/hqfsm4nuqbtx","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4328,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4328},"cache":{},"timings":{"send":5,"wait":40,"receive":24}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.721Z","time":137,"request":{"method":"GET","url":"https://example.invalid/w68tpo93om7l","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11590,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11590},"cache":{},"timings":{"send":1,"wait":112,"receive":24}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.734Z","time":139,"request":{"method":"GET","url":"https://example.invalid/56ydyfa28jky","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10191,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10191},"cache":{},"timings":{"send":3,"wait":101,"receive":35}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.755Z","time":48,"request":{"method":"GET","url":"https://example.invalid/fjzkuoag6sst","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5247,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5247},"cache":{},"timings":{"send":4,"wait":26,"receive":18}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.758Z","time":52,"request":{"method":"GET","url":"https://example.org/g3z0v3s0ayy6","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11472,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11472},"cache":{},"timings":{"send":6,"wait":38,"receive":8}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.767Z","time":44,"request":{"method":"GET","url":"https://example.test/n4juxunxmod5","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5092,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5092},"cache":{},"timings":{"send":1,"wait":41,"receive":2}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.777Z","time":55,"request":{"method":"GET","url":"https://example.edu/kmvk1vj9ya2t","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3227,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3227},"cache":{},"timings":{"send":4,"wait":16,"receive":35}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.783Z","time":137,"request":{"method":"GET","url":"https://example.invalid/0x3z1frnripc","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6419,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6419},"cache":{},"timings":{"send":2,"wait":100,"receive":35}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.820Z","time":129,"request":{"method":"GET","url":"https://example.edu/ft2x8upgsn4n","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2460,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2460},"cache":{},"timings":{"send":4,"wait":120,"receive":5}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.826Z","time":76,"request":{"method":"GET","url":"https://iana.org/kuo7m441ncyf","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7462,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7462},"cache":{},"timings":{"send":6,"wait":52,"receive":18}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.854Z","time":126,"request":{"method":"GET","url":"https://example.test/lls0mq3jwcrb","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7457,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7457},"cache":{},"timings":{"send":5,"wait":110,"receive":11}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.857Z","time":43,"request":{"method":"GET","url":"https://example.invalid/idkpf08i3axi","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10998,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10998},"cache":{},"timings":{"send":8,"wait":23,"receive":12}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.890Z","time":99,"request":{"method":"GET","url":"https://example.io/a8i51dw043sa","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1937,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1937},"cache":{},"timings":{"send":7,"wait":68,"receive":24}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.896Z","time":123,"request":{"method":"GET","url":"https://example.net/f42rh6t88qvm","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6553,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6553},"cache":{},"timings":{"send":5,"wait":100,"receive":18}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.908Z","time":92,"request":{"method":"GET","url":"https://example.dev/moudeztwtr0d","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2693,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2693},"cache":{},"timings":{"send":3,"wait":65,"receive":24}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.908Z","time":24,"request":{"method":"GET","url":"https://httpbin.org/vm2hglb1ri3o","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9702,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9702},"cache":{},"timings":{"send":4,"wait":13,"receive":7}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.927Z","time":124,"request":{"method":"GET","url":"https://example.invalid/99lbqwkwk7fx","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4193,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4193},"cache":{},"timings":{"send":3,"wait":114,"receive":7}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.941Z","time":70,"request":{"method":"GET","url":"https://example.org/mnivgu0nkpu7","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1090,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1090},"cache":{},"timings":{"send":5,"wait":38,"receive":27}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.957Z","time":55,"request":{"method":"GET","url":"https://example.test/rv14i0q84hg6","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5060,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5060},"cache":{},"timings":{"send":8,"wait":42,"receive":5}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.971Z","time":76,"request":{"method":"GET","url":"https://example.org/4y35sn6hdvgu","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6835,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6835},"cache":{},"timings":{"send":7,"wait":56,"receive":13}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.983Z","time":58,"request":{"method":"GET","url":"https://example.io/tteis6oa5ow4","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8112,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8112},"cache":{},"timings":{"send":6,"wait":27,"receive":25}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.990Z","time":86,"request":{"method":"GET","url":"https://example.org/43rnorr4ejz3","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6715,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6715},"cache":{},"timings":{"send":2,"wait":55,"receive":29}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:35.990Z","time":88,"request":{"method":"GET","url":"https://example.invalid/793vuyyvvcmy","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8184,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8184},"cache":{},"timings":{"send":3,"wait":76,"receive":9}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.023Z","time":57,"request":{"method":"GET","url":"https://example.io/zz9rcwis33bo","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7049,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7049},"cache":{},"timings":{"send":1,"wait":31,"receive":25}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.030Z","time":56,"request":{"method":"GET","url":"https://example.com/vhb5mph7p3sl","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4127,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4127},"cache":{},"timings":{"send":5,"wait":32,"receive":19}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.068Z","time":41,"request":{"method":"GET","url":"https://iana.org/spx68th8y9ug","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3305,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3305},"cache":{},"timings":{"send":7,"wait":29,"receive":5}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.072Z","time":119,"request":{"method":"GET","url":"https://example.com/w2on2ryxbm2x","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5882,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5882},"cache":{},"timings":{"send":8,"wait":105,"receive":6}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.096Z","time":99,"request":{"method":"GET","url":"https://iana.org/upwahra264p0","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4069,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4069},"cache":{},"timings":{"send":4,"wait":69,"receive":26}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.102Z","time":68,"request":{"method":"GET","url":"https://example.test/szbrd387avl2","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8134,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8134},"cache":{},"timings":{"send":4,"wait":39,"receive":25}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.107Z","time":152,"request":{"method":"GET","url":"https://example.net/w6o36twvtolr","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6116,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6116},"cache":{},"timings":{"send":6,"wait":115,"receive":31}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.117Z","time":86,"request":{"method":"GET","url":"https://example.invalid/lfw02dfbf5ss","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5998,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5998},"cache":{},"timings":{"send":3,"wait":75,"receive":8}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.129Z","time":111,"request":{"method":"GET","url":"https://example.com/o7etkwxewkyz","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4380,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4380},"cache":{},"timings":{"send":3,"wait":99,"receive":9}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.151Z","time":46,"request":{"method":"GET","url":"https://httpbin.org/mcp6ninoo66k","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3612,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3612},"cache":{},"timings":{"send":6,"wait":25,"receive":15}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.166Z","time":70,"request":{"method":"GET","url":"https://httpbin.org/8tuh22u3qmva","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3155,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3155},"cache":{},"timings":{"send":8,"wait":43,"receive":19}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.194Z","time":32,"request":{"method":"GET","url":"https://example.test/3wezx62a1403","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8235,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8235},"cache":{},"timings":{"send":6,"wait":21,"receive":5}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.202Z","time":51,"request":{"method":"GET","url":"https://example.test/gpu4ax76o82x","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1597,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1597},"cache":{},"timings":{"send":7,"wait":34,"receive":10}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.208Z","time":160,"request":{"method":"GET","url":"https://example.net/2wxdf848btde","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3126,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3126},"cache":{},"timings":{"send":7,"wait":119,"receive":34}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.208Z","time":85,"request":{"method":"GET","url":"https://iana.org/k8epw12lfx9p","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6814,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6814},"cache":{},"timings":{"send":8,"wait":47,"receive":30}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.212Z","time":21,"request":{"method":"GET","url":"https://example.io/k8a1gjqdjlm8","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6931,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6931},"cache":{},"timings":{"send":8,"wait":11,"receive":2}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.224Z","time":113,"request":{"method":"GET","url":"https://example.com/12jqeo6e7yqu","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11642,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11642},"cache":{},"timings":{"send":8,"wait":97,"receive":8}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.234Z","time":38,"request":{"method":"GET","url":"https://example.io/nlxbd4ir1f0u","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5514,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5514},"cache":{},"timings":{"send":1,"wait":21,"receive":16}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.245Z","time":102,"request":{"method":"GET","url":"https://example.dev/3icthfed8vtb","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1574,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1574},"cache":{},"timings":{"send":7,"wait":76,"receive":19}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.246Z","time":73,"request":{"method":"GET","url":"https://httpbin.org/b42an64lyysh","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8423,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8423},"cache":{},"timings":{"send":4,"wait":50,"receive":19}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.259Z","time":135,"request":{"method":"GET","url":"https://example.com/cyig184n1wdx","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7093,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7093},"cache":{},"timings":{"send":2,"wait":110,"receive":23}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.267Z","time":100,"request":{"method":"GET","url":"https://example.edu/dzbnmqppbb5d","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6839,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6839},"cache":{},"timings":{"send":2,"wait":83,"receive":15}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.277Z","time":138,"request":{"method":"GET","url":"https://example.com/s16zqwwib2w5","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":526,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":526},"cache":{},"timings":{"send":2,"wait":113,"receive":23}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.286Z","time":51,"request":{"method":"GET","url":"https://example.test/lib2y33am7iy","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7786,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7786},"cache":{},"timings":{"send":2,"wait":17,"receive":32}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.286Z","time":120,"request":{"method":"GET","url":"https://example.edu/318atlfvdw7l","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10768,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10768},"cache":{},"timings":{"send":6,"wait":106,"receive":8}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.306Z","time":25,"request":{"method":"GET","url":"https://iana.org/gyg8q6vak4h4","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2069,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2069},"cache":{},"timings":{"send":1,"wait":15,"receive":9}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.326Z","time":92,"request":{"method":"GET","url":"https://iana.org/fzdmjvmj6amn","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5701,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5701},"cache":{},"timings":{"send":3,"wait":59,"receive":30}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.356Z","time":91,"request":{"method":"GET","url":"https://example.com/ljrkr6yojhcg","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5600,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5600},"cache":{},"timings":{"send":2,"wait":55,"receive":34}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.365Z","time":57,"request":{"method":"GET","url":"https://iana.org/sdenzml4is9o","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7630,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7630},"cache":{},"timings":{"send":8,"wait":17,"receive":32}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.367Z","time":70,"request":{"method":"GET","url":"https://example.com/h4ipnklggol4","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7609,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7609},"cache":{},"timings":{"send":3,"wait":55,"receive":12}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.387Z","time":98,"request":{"method":"GET","url":"https://example.test/g04vfbz9nnqa","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9046,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9046},"cache":{},"timings":{"send":7,"wait":57,"receive":34}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.401Z","time":107,"request":{"method":"GET","url":"https://example.net/ljj0f1ej2kvt","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6829,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6829},"cache":{},"timings":{"send":4,"wait":75,"receive":28}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.437Z","time":104,"request":{"method":"GET","url":"https://example.invalid/sklflzton6p5","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5482,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5482},"cache":{},"timings":{"send":3,"wait":86,"receive":15}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.450Z","time":73,"request":{"method":"GET","url":"https://iana.org/yqgrxt6cmhb4","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7594,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7594},"cache":{},"timings":{"send":4,"wait":60,"receive":9}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.464Z","time":89,"request":{"method":"GET","url":"https://example.test/cmszc6b0v044","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2356,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2356},"cache":{},"timings":{"send":7,"wait":57,"receive":25}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.477Z","time":61,"request":{"method":"GET","url":"https://example.dev/8ojemfqm45kf","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3666,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3666},"cache":{},"timings":{"send":3,"wait":53,"receive":5}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.477Z","time":45,"request":{"method":"GET","url":"https://example.io/drmyeys9vc5t","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3169,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3169},"cache":{},"timings":{"send":5,"wait":36,"receive":4}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.480Z","time":106,"request":{"method":"GET","url":"https://example.com/x32rffbom8v5","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5203,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5203},"cache":{},"timings":{"send":8,"wait":67,"receive":31}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.486Z","time":146,"request":{"method":"GET","url":"https://iana.org/euk6r99alq46","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11840,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11840},"cache":{},"timings":{"send":3,"wait":115,"receive":28}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.487Z","time":116,"request":{"method":"GET","url":"https://iana.org/ksbckon5eoi2","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8587,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8587},"cache":{},"timings":{"send":4,"wait":109,"receive":3}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.488Z","time":89,"request":{"method":"GET","url":"https://example.org/vtq13pp6zaub","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4016,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4016},"cache":{},"timings":{"send":3,"wait":69,"receive":17}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.496Z","time":114,"request":{"method":"GET","url":"https://httpbin.org/qt7dx1z9h7n3","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4555,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4555},"cache":{},"timings":{"send":3,"wait":89,"receive":22}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.527Z","time":34,"request":{"method":"GET","url":"https://iana.org/89155q85oy3y","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6065,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6065},"cache":{},"timings":{"send":2,"wait":18,"receive":14}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.529Z","time":101,"request":{"method":"GET","url":"https://example.edu/dzjxzblfe1v1","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5317,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5317},"cache":{},"timings":{"send":5,"wait":82,"receive":14}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.531Z","time":82,"request":{"method":"GET","url":"https://example.edu/pd0lywlpxegt","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5541,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5541},"cache":{},"timings":{"send":5,"wait":64,"receive":13}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.536Z","time":130,"request":{"method":"GET","url":"https://example.test/azhvdyl4qnfj","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3167,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3167},"cache":{},"timings":{"send":4,"wait":117,"receive":9}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.542Z","time":131,"request":{"method":"GET","url":"https://example.edu/9lhqyivd0qgx","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9666,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9666},"cache":{},"timings":{"send":6,"wait":99,"receive":26}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.550Z","time":125,"request":{"method":"GET","url":"https://example.io/xo2rhqmr90t1","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11711,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11711},"cache":{},"timings":{"send":4,"wait":101,"receive":20}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.572Z","time":115,"request":{"method":"GET","url":"https://example.dev/qci6txors4st","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1822,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1822},"cache":{},"timings":{"send":6,"wait":98,"receive":11}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.576Z","time":113,"request":{"method":"GET","url":"https://example.com/s52bp0s407fv","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8166,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8166},"cache":{},"timings":{"send":6,"wait":96,"receive":11}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.587Z","time":62,"request":{"method":"GET","url":"https://iana.org/mbk5vn6k4g7i","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7714,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7714},"cache":{},"timings":{"send":5,"wait":52,"receive":5}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.602Z","time":137,"request":{"method":"GET","url":"https://example.dev/tim9kr39ttv1","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9325,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9325},"cache":{},"timings":{"send":4,"wait":101,"receive":32}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.611Z","time":36,"request":{"method":"GET","url":"https://httpbin.org/y066cx03249e","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":956,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":956},"cache":{},"timings":{"send":2,"wait":29,"receive":5}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.626Z","time":49,"request":{"method":"GET","url":"https://example.dev/n4baw2xeyu18","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1673,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1673},"cache":{},"timings":{"send":1,"wait":45,"receive":3}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.630Z","time":51,"request":{"method":"GET","url":"https://example.net/nuw3xt587jm6","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11538,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11538},"cache":{},"timings":{"send":3,"wait":29,"receive":19}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.653Z","time":77,"request":{"method":"GET","url":"https://example.test/z8ptte1e1bn4","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5154,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5154},"cache":{},"timings":{"send":4,"wait":65,"receive":8}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.657Z","time":96,"request":{"method":"GET","url":"https://example.edu/v1a4ikzvlrzf","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3680,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3680},"cache":{},"timings":{"send":2,"wait":87,"receive":7}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.661Z","time":123,"request":{"method":"GET","url":"https://example.io/5lvert3bsryj","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6529,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6529},"cache":{},"timings":{"send":6,"wait":87,"receive":30}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.663Z","time":78,"request":{"method":"GET","url":"https://iana.org/5jgmpbscza57","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10355,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10355},"cache":{},"timings":{"send":2,"wait":59,"receive":17}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.674Z","time":97,"request":{"method":"GET","url":"https://iana.org/6radtbp70foj","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10803,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10803},"cache":{},"timings":{"send":1,"wait":78,"receive":18}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.678Z","time":53,"request":{"method":"GET","url":"https://example.org/ztans84a3lzl","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3575,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3575},"cache":{},"timings":{"send":1,"wait":32,"receive":20}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.688Z","time":115,"request":{"method":"GET","url":"https://example.net/59wlujbzzhpy","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9457,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9457},"cache":{},"timings":{"send":4,"wait":109,"receive":2}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.689Z","time":122,"request":{"method":"GET","url":"https://example.com/6h8lc71usyxi","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1084,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1084},"cache":{},"timings":{"send":4,"wait":87,"receive":31}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.695Z","time":81,"request":{"method":"GET","url":"https://example.invalid/giwcvvm9rwvp","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2371,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2371},"cache":{},"timings":{"send":6,"wait":67,"receive":8}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.702Z","time":93,"request":{"method":"GET","url":"https://example.invalid/luw4msxgkrz3","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1883,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1883},"cache":{},"timings":{"send":7,"wait":52,"receive":34}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.709Z","time":40,"request":{"method":"GET","url":"https://httpbin.org/go6o7mog81zh","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3441,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3441},"cache":{},"timings":{"send":4,"wait":34,"receive":2}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.760Z","time":117,"request":{"method":"GET","url":"https://iana.org/lfo80udp9u87","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9511,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9511},"cache":{},"timings":{"send":3,"wait":86,"receive":28}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.788Z","time":137,"request":{"method":"GET","url":"https://example.dev/e1j278cywl2q","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1890,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1890},"cache":{},"timings":{"send":1,"wait":113,"receive":23}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.793Z","time":33,"request":{"method":"GET","url":"https://example.net/ou3oai1kj68d","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1847,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1847},"cache":{},"timings":{"send":6,"wait":25,"receive":2}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.799Z","time":63,"request":{"method":"GET","url":"https://example.org/4tx204hu6wgz","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1602,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1602},"cache":{},"timings":{"send":5,"wait":49,"receive":9}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.812Z","time":85,"request":{"method":"GET","url":"https://example.net/gq3cdmvkfel8","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7465,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7465},"cache":{},"timings":{"send":8,"wait":52,"receive":25}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.815Z","time":66,"request":{"method":"GET","url":"https://httpbin.org/jr8lc978k88h","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9698,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9698},"cache":{},"timings":{"send":7,"wait":30,"receive":29}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.817Z","time":114,"request":{"method":"GET","url":"https://httpbin.org/s4hslmlqyzxm","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6050,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6050},"cache":{},"timings":{"send":6,"wait":81,"receive":27}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.838Z","time":51,"request":{"method":"GET","url":"https://example.org/ojo6mg09pr4j","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4107,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4107},"cache":{},"timings":{"send":2,"wait":38,"receive":11}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.839Z","time":62,"request":{"method":"GET","url":"https://iana.org/bcf5jox5z5tg","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7040,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7040},"cache":{},"timings":{"send":8,"wait":48,"receive":6}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.854Z","time":109,"request":{"method":"GET","url":"https://example.dev/xcdhm67xw4qj","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4778,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4778},"cache":{},"timings":{"send":3,"wait":91,"receive":15}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.855Z","time":129,"request":{"method":"GET","url":"https://example.io/nv8e6kyvisp7","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11043,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11043},"cache":{},"timings":{"send":6,"wait":115,"receive":8}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.856Z","time":47,"request":{"method":"GET","url":"https://example.net/f7lpv7888ozj","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5509,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5509},"cache":{},"timings":{"send":5,"wait":16,"receive":26}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.876Z","time":117,"request":{"method":"GET","url":"https://example.com/pmdplru9lspp","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5324,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5324},"cache":{},"timings":{"send":2,"wait":81,"receive":34}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.882Z","time":64,"request":{"method":"GET","url":"https://example.org/mmqkj7li1vhe","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5265,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5265},"cache":{},"timings":{"send":7,"wait":23,"receive":34}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.946Z","time":70,"request":{"method":"GET","url":"https://example.edu/shsio8cnev9o","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":915,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":915},"cache":{},"timings":{"send":4,"wait":56,"receive":10}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:36.955Z","time":119,"request":{"method":"GET","url":"https://example.org/fgfipj601uf7","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7184,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7184},"cache":{},"timings":{"send":7,"wait":88,"receive":24}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.007Z","time":114,"request":{"method":"GET","url":"https://example.invalid/zaaf68zm47sr","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2746,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2746},"cache":{},"timings":{"send":5,"wait":87,"receive":22}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.028Z","time":65,"request":{"method":"GET","url":"https://example.com/djcm4zyei5jn","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9774,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9774},"cache":{},"timings":{"send":2,"wait":57,"receive":6}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.036Z","time":91,"request":{"method":"GET","url":"https://example.io/wuan88hjha74","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11209,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11209},"cache":{},"timings":{"send":1,"wait":57,"receive":33}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.051Z","time":56,"request":{"method":"GET","url":"https://example.org/l2gkaa5fe4hs","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1641,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1641},"cache":{},"timings":{"send":4,"wait":25,"receive":27}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.061Z","time":46,"request":{"method":"GET","url":"https://example.dev/47kaireq5ur2","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2183,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2183},"cache":{},"timings":{"send":2,"wait":32,"receive":12}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.074Z","time":73,"request":{"method":"GET","url":"https://example.net/p2v53xdbe2h4","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2525,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2525},"cache":{},"timings":{"send":1,"wait":45,"receive":27}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.075Z","time":36,"request":{"method":"GET","url":"https://example.org/cahbr06ybi2u","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9514,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9514},"cache":{},"timings":{"send":1,"wait":21,"receive":14}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.077Z","time":113,"request":{"method":"GET","url":"https://example.invalid/t33a0e5ae6vs","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1871,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1871},"cache":{},"timings":{"send":1,"wait":88,"receive":24}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.078Z","time":129,"request":{"method":"GET","url":"https://example.dev/26hvgs24rucj","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3472,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3472},"cache":{},"timings":{"send":3,"wait":99,"receive":27}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.079Z","time":61,"request":{"method":"GET","url":"https://example.invalid/vv8h2zi7tbh9","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11142,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11142},"cache":{},"timings":{"send":5,"wait":27,"receive":29}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.081Z","time":73,"request":{"method":"GET","url":"https://example.org/hqqgs99580oy","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6688,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6688},"cache":{},"timings":{"send":3,"wait":43,"receive":27}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.096Z","time":76,"request":{"method":"GET","url":"https://example.invalid/xqxso2d9gskx","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":675,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":675},"cache":{},"timings":{"send":4,"wait":61,"receive":11}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.106Z","time":78,"request":{"method":"GET","url":"https://example.io/ercklsyo2kmz","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9443,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9443},"cache":{},"timings":{"send":2,"wait":71,"receive":5}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.113Z","time":97,"request":{"method":"GET","url":"https://example.test/dapxwn65heel","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6630,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6630},"cache":{},"timings":{"send":1,"wait":74,"receive":22}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.130Z","time":77,"request":{"method":"GET","url":"https://example.dev/k65x18p8gybg","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6423,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6423},"cache":{},"timings":{"send":2,"wait":69,"receive":6}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.157Z","time":77,"request":{"method":"GET","url":"https://example.net/fyr0k2pwjpur","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":536,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":536},"cache":{},"timings":{"send":6,"wait":36,"receive":35}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.164Z","time":104,"request":{"method":"GET","url":"https://example.io/5feyrmyjk0yq","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5544,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5544},"cache":{},"timings":{"send":6,"wait":76,"receive":22}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.169Z","time":65,"request":{"method":"GET","url":"https://example.org/m1niak9gaksz","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8809,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8809},"cache":{},"timings":{"send":8,"wait":48,"receive":9}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.184Z","time":103,"request":{"method":"GET","url":"https://example.net/veveaips0ssy","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2233,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2233},"cache":{},"timings":{"send":4,"wait":75,"receive":24}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.195Z","time":125,"request":{"method":"GET","url":"https://httpbin.org/ogbc8w1t8tlq","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7388,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7388},"cache":{},"timings":{"send":7,"wait":113,"receive":5}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.198Z","time":66,"request":{"method":"GET","url":"https://example.edu/uo4oupodjzn0","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":5210,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":5210},"cache":{},"timings":{"send":5,"wait":36,"receive":25}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.200Z","time":57,"request":{"method":"GET","url":"https://iana.org/q9neuayalji2","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":4955,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":4955},"cache":{},"timings":{"send":1,"wait":30,"receive":26}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.230Z","time":101,"request":{"method":"GET","url":"https://example.org/c4oan1hv5v09","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":6519,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":6519},"cache":{},"timings":{"send":7,"wait":86,"receive":8}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.250Z","time":124,"request":{"method":"GET","url":"https://example.com/ykewt3dnxh6o","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":3479,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":3479},"cache":{},"timings":{"send":1,"wait":102,"receive":21}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.265Z","time":70,"request":{"method":"GET","url":"https://httpbin.org/p7t4isuvhjak","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9686,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9686},"cache":{},"timings":{"send":5,"wait":31,"receive":34}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.301Z","time":60,"request":{"method":"GET","url":"https://httpbin.org/p119syld1397","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10153,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10153},"cache":{},"timings":{"send":8,"wait":32,"receive":20}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.321Z","time":102,"request":{"method":"GET","url":"https://example.dev/8zjw63u5zu6y","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":8373,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":8373},"cache":{},"timings":{"send":3,"wait":65,"receive":34}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.331Z","time":79,"request":{"method":"GET","url":"https://iana.org/8qmo0qax59rp","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":888,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":888},"cache":{},"timings":{"send":4,"wait":54,"receive":21}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.335Z","time":73,"request":{"method":"GET","url":"https://httpbin.org/ywjw4t0zhfiq","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9472,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9472},"cache":{},"timings":{"send":6,"wait":46,"receive":21}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.339Z","time":37,"request":{"method":"GET","url":"https://example.invalid/ifn8d8v5gkma","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":7249,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":7249},"cache":{},"timings":{"send":2,"wait":23,"receive":12}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.372Z","time":124,"request":{"method":"GET","url":"https://httpbin.org/44hd8qvv8fgd","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9626,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9626},"cache":{},"timings":{"send":1,"wait":120,"receive":3}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.383Z","time":139,"request":{"method":"GET","url":"https://iana.org/qevatq1szihn","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9862,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9862},"cache":{},"timings":{"send":5,"wait":109,"receive":25}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.394Z","time":77,"request":{"method":"GET","url":"https://iana.org/aobfy6juvwrb","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":2336,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":2336},"cache":{},"timings":{"send":8,"wait":59,"receive":10}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.448Z","time":114,"request":{"method":"GET","url":"https://example.edu/lb43dk4vfvaa","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9195,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9195},"cache":{},"timings":{"send":1,"wait":110,"receive":3}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.451Z","time":96,"request":{"method":"GET","url":"https://iana.org/w60z3w0k7onx","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":1284,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":1284},"cache":{},"timings":{"send":8,"wait":68,"receive":20}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.465Z","time":92,"request":{"method":"GET","url":"https://example.net/xfmalqezs058","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":11494,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":11494},"cache":{},"timings":{"send":8,"wait":62,"receive":22}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.488Z","time":101,"request":{"method":"GET","url":"https://example.invalid/5osyekrubw4n","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":10687,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":10687},"cache":{},"timings":{"send":3,"wait":78,"receive":20}},{"pageref":"page_0","startedDateTime":"2026-01-30T09:32:37.494Z","time":43,"request":{"method":"GET","url":"https://example.edu/p0zkmkam2yt5","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"User-Agent","value":"SyntheticHAR/1.0"},{"name":"Accept","value":"*/*"}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"content":{"size":9522,"mimeType":"text/html; charset=utf-8"},"redirectURL":"","headersSize":-1,"bodySize":9522},"cache":{},"timings":{"send":5,"wait":12,"receive":26}}]}} \ No newline at end of file diff --git a/proxy_netbench/run.py b/proxy_netbench/run.py index d35ba3b5..02b44365 100755 --- a/proxy_netbench/run.py +++ b/proxy_netbench/run.py @@ -403,7 +403,12 @@ def print_comparison_section( def main() -> int: ap = argparse.ArgumentParser() - ap.add_argument("--with-proxy", action="store_true") + ap.add_argument( + "--proxy", + default="direct", + choices=["direct", "global", "scoped"], + help="global vs scoped only has impact on mock (gen) data", + ) ap.add_argument( "--scenario", default="baseline", @@ -458,6 +463,8 @@ def main() -> int: procs: List[Proc] = [] keep_data = bool(args.report_file) + proxy_is_enabled = args.proxy and args.proxy != "direct" + def cleanup(keep: bool) -> None: for pr in reversed(procs): terminate_process(pr) @@ -465,11 +472,6 @@ def cleanup(keep: bool) -> None: if keep: return - try: - shutil.rmtree(data_dir) - except Exception: - pass - atexit.register(lambda: cleanup(keep_data)) # Start mock server @@ -499,7 +501,7 @@ def cleanup(keep: bool) -> None: eprint("mock:", mock_addr) # Start proxy optionally - if args.with_proxy: + if proxy_is_enabled: eprint("proxy: starting") proxy_argv = [ netbench, @@ -534,15 +536,18 @@ def cleanup(keep: bool) -> None: ] if har_path is not None: - # HAR mode: runner replays requests run_argv += ["--replay", str(har_path)] if args.emulate: run_argv.append("--emulate") else: run_argv += ["--scenario", args.scenario] - if args.with_proxy: + if proxy_is_enabled: run_argv.append("--proxy") + if args.proxy == "global": + run_argv += ["--products", "none, vscode; q=0.2, pypi; q=0.1"] + elif args.proxy == "scoped": + run_argv += ["--products", "vscode; q=0.6, pypi; q=0.4"] run_argv.append(target_addr) @@ -601,7 +606,7 @@ def cleanup(keep: bool) -> None: f"- data dir: {data_dir}\n" f"- jsonl: {run_jsonl}\n" f"- mock log: {mock_log}\n" - f"- proxy log: {proxy_log if args.with_proxy else '(no proxy)'}\n" + f"- proxy log: {proxy_log if args.proxy else '(no proxy)'}\n" f"- run log: {run_log}\n" ) @@ -643,7 +648,7 @@ def cleanup(keep: bool) -> None: print() print("logs") print(str(mock_log)) - if args.with_proxy: + if args.proxy_enabled: print(str(proxy_log)) print(str(run_log)) return 0 @@ -653,12 +658,10 @@ def cleanup(keep: bool) -> None: print("netbench finished") if har_path is not None: print( - f"mode=har har={har_path} emulate={'yes' if args.emulate else 'no'} proxied={'yes' if args.with_proxy else 'no'}" + f"mode=har har={har_path} emulate={'yes' if args.emulate else 'no'} proxy={args.proxy}" ) else: - print( - f"mode=scenario scenario={args.scenario} proxied={'yes' if args.with_proxy else 'no'}" - ) + print(f"mode=scenario scenario={args.scenario} proxied={args.proxy}") print() summaries = [e for e in events if e.get("type") == "summary"] @@ -708,7 +711,7 @@ def cleanup(keep: bool) -> None: print(f"jsonl: {run_jsonl}") print(f"summary: {run_summary}") print(f"mock log: {mock_log}") - if args.with_proxy: + if proxy_is_enabled: print(f"proxy log: {proxy_log}") print(f"run log: {run_log}") print() diff --git a/proxy_netbench/src/cmd/mock/fake_reporter.rs b/proxy_netbench/src/cmd/mock/fake_reporter.rs new file mode 100644 index 00000000..7340a4b8 --- /dev/null +++ b/proxy_netbench/src/cmd/mock/fake_reporter.rs @@ -0,0 +1,47 @@ +use std::{ + convert::Infallible, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use rama::{ + Service, + http::{ + Request, Response, StatusCode, + service::web::{ + Router, + extract::{Json, State}, + response::IntoResponse, + }, + }, +}; + +pub(super) fn fake_reporter_svc() +-> impl Service + Clone { + Arc::new( + Router::new_with_state(TotalCounters::default()) + .with_post("/blocked-events", blocked_events) + .with_get("/counter/blocked-events", blocked_events_counter), + ) +} + +#[derive(Debug, Clone, Default)] +struct TotalCounters { + blocked_events: Arc, +} + +async fn blocked_events( + State(TotalCounters { blocked_events }): State, + Json(_): Json, +) -> impl IntoResponse { + let _ = blocked_events.fetch_add(1, Ordering::SeqCst); + StatusCode::OK +} + +async fn blocked_events_counter( + State(TotalCounters { blocked_events }): State, +) -> impl IntoResponse { + blocked_events.load(Ordering::Acquire).to_string() +} diff --git a/proxy_netbench/src/cmd/mock/mod.rs b/proxy_netbench/src/cmd/mock/mod.rs index bc33ce44..af06c656 100644 --- a/proxy_netbench/src/cmd/mock/mod.rs +++ b/proxy_netbench/src/cmd/mock/mod.rs @@ -13,10 +13,11 @@ use rama::{ compression::CompressionLayer, required_header::AddRequiredResponseHeadersLayer, trace::TraceLayer, }, + matcher::DomainMatcher, server::HttpServer, service::web::response::IntoResponse, }, - layer::{AbortableLayer, TimeoutLayer, abort::AbortController}, + layer::{AbortableLayer, HijackLayer, TimeoutLayer, abort::AbortController}, net::{ socket::Interface, tls::{ @@ -37,6 +38,7 @@ use safechain_proxy_lib::{ use crate::{ config::{Scenario, ServerConfig}, + definitions, http::{ MockReplayIndex, MockResponseRandomIndex, har::{self, HarEntry}, @@ -44,6 +46,8 @@ use crate::{ }, }; +mod fake_reporter; + #[derive(Debug, Clone, Args)] /// run bench mock server pub struct MockCommand { @@ -91,6 +95,10 @@ pub async fn exec( CompressionLayer::new(), AddRequiredResponseHeadersLayer::new() .with_server_header_value(HeaderValue::from_static(utils::env::server_identifier())), + HijackLayer::new( + DomainMatcher::exact(definitions::FAKE_AIKIDO_REPORTER_DOMAIN), + self::fake_reporter::fake_reporter_svc(), + ), ) .into_layer(Arc::new( MockHttpServer::try_new(data.clone(), args.replay.clone(), merged_cfg).await?, diff --git a/proxy_netbench/src/cmd/proxy/mod.rs b/proxy_netbench/src/cmd/proxy/mod.rs index 9727d56b..6e294666 100644 --- a/proxy_netbench/src/cmd/proxy/mod.rs +++ b/proxy_netbench/src/cmd/proxy/mod.rs @@ -14,6 +14,8 @@ use rama::{ use clap::Args; use safechain_proxy_lib::{client, diagnostics, firewall, server, storage, tls}; +use crate::definitions; + #[derive(Debug, Clone, Args)] /// run proxy in function of benchmarker pub struct ProxyCommand { @@ -39,9 +41,9 @@ pub struct ProxyCommand { /// Record the entire proxy traffic to a HAR file. pub record_har: bool, - /// Optional endpoint URL to POST blocked-event notifications to. - #[arg(long, value_name = "URL")] - pub reporting_endpoint: Option, + #[arg(long, default_value_t = false)] + /// Optionally disable reporting + pub disable_reporting: bool, } pub async fn exec( @@ -71,6 +73,19 @@ pub async fn exec( tracing::info!(path = ?data, "write new (tmp) root CA to disk"); server::write_root_ca_as_file(&data, &root_ca).await?; + // by default take into account reporting in benchmarks, + // as it will usually be enabled, so we might as well see it in the numbers + let maybe_reporting_endpoint: Option = (!args.disable_reporting) + .then(|| { + format!( + "https://{}/blocked-events", + definitions::FAKE_AIKIDO_REPORTER_DOMAIN + ) + .parse() + .context("parse reporter fake domain") + }) + .transpose()?; + // ensure to not wait for firewall creation in case shutdown was initiated, // this can happen for example in case remote lists need to be fetched and the // something on the network on either side is not working @@ -78,7 +93,7 @@ pub async fn exec( result = firewall::Firewall::try_new( guard.clone(), data_storage, - args.reporting_endpoint.clone(), + maybe_reporting_endpoint, ) => { result? } diff --git a/proxy_netbench/src/cmd/run/client.rs b/proxy_netbench/src/cmd/run/client.rs index ddab0a92..abe93065 100644 --- a/proxy_netbench/src/cmd/run/client.rs +++ b/proxy_netbench/src/cmd/run/client.rs @@ -1,15 +1,29 @@ +use std::time::Duration; + use rama::{ - Layer as _, Service as _, - error::OpaqueError, - http::{Request, Response}, - layer::AddInputExtensionLayer, + Layer as _, Service, + error::{ErrorContext as _, OpaqueError}, + http::{ + Body, Request, Response, + layer::{ + decompression::DecompressionLayer, + map_request_body::MapRequestBodyLayer, + map_response_body::MapResponseBodyLayer, + retry::{ManagedPolicy, RetryLayer}, + timeout::TimeoutLayer, + }, + }, + layer::{AddInputExtensionLayer, MapErrLayer}, net::{ Protocol, address::{ProxyAddress, SocketAddress}, + client::pool::http::HttpPooledConnectorConfig, }, rt::Executor, service::BoxService, + utils::{backoff::ExponentialBackoff, rng::HasherRng}, }; + use safechain_proxy_lib::client::{ WebClientConfig, new_web_client, transport::try_set_egress_address_overwrite, }; @@ -17,19 +31,62 @@ use safechain_proxy_lib::client::{ pub fn http_cient( exec: Executor, target: SocketAddress, + concurrency: usize, proxy: bool, ) -> Result, OpaqueError> { try_set_egress_address_overwrite(target)?; + let pool_cfg = HttpPooledConnectorConfig { + max_total: concurrency * 3, + max_active: concurrency * 2, + wait_for_pool_timeout: Some(Duration::from_secs(5)), + idle_timeout: Some(Duration::from_secs(3)), + }; + if proxy { - Ok(AddInputExtensionLayer::new(ProxyAddress { - protocol: Some(Protocol::HTTP), - address: target.into(), - credential: None, - }) - .into_layer(new_web_client(exec, WebClientConfig::default())?) - .boxed()) + http_client_with_shared_layers( + AddInputExtensionLayer::new(ProxyAddress { + protocol: Some(Protocol::HTTP), + address: target.into(), + credential: None, + }) + .into_layer(new_web_client( + exec, + WebClientConfig::without_overwrites().with_pool_cfg(pool_cfg), + )?), + ) } else { - Ok(new_web_client(exec, WebClientConfig::without_overwrites())?.boxed()) + http_client_with_shared_layers(new_web_client( + exec, + WebClientConfig::default().with_pool_cfg(pool_cfg), + )?) } } + +fn http_client_with_shared_layers( + inner_svc: S, +) -> Result, OpaqueError> +where + S: Service, +{ + Ok(( + MapResponseBodyLayer::new(Body::new), + MapErrLayer::new(OpaqueError::from_std), + TimeoutLayer::new(Duration::from_secs(3)), + RetryLayer::new( + ManagedPolicy::default().with_backoff( + ExponentialBackoff::new( + Duration::from_millis(100), + Duration::from_secs(2), + 0.01, + HasherRng::default, + ) + .context("create exponential backoff impl")?, + ), + ), + DecompressionLayer::new(), + MapRequestBodyLayer::new(Body::new), + ) + .into_layer(inner_svc) + .boxed()) +} diff --git a/proxy_netbench/src/cmd/run/mod.rs b/proxy_netbench/src/cmd/run/mod.rs index 717a5d1a..5d50809b 100644 --- a/proxy_netbench/src/cmd/run/mod.rs +++ b/proxy_netbench/src/cmd/run/mod.rs @@ -4,7 +4,7 @@ use rama::{ Service as _, error::{ErrorContext as _, OpaqueError}, graceful::ShutdownGuard, - http::Response, + http::{body::util::BodyExt, response::Parts}, net::address::SocketAddress, rt::Executor, telemetry::tracing, @@ -62,7 +62,7 @@ pub struct RunCommand { warmup: f64, /// Amount of times we run through the samples - #[arg(long, default_value_t = 2)] + #[arg(long, default_value_t = 3)] iterations: usize, #[arg(long, value_parser = parse_product_values)] @@ -97,10 +97,6 @@ pub async fn exec( guard: ShutdownGuard, args: RunCommand, ) -> Result<(), OpaqueError> { - let client = - self::client::http_cient(Executor::graceful(guard.clone()), args.target, args.proxy) - .context("create HTTP(S) client")?; - let merged_cfg = merge_server_cfg(args.scenario, args.config); let target_rps = merged_cfg.target_rps.unwrap_or(200).max(1); @@ -119,6 +115,14 @@ pub async fn exec( } }; + let client = self::client::http_cient( + Executor::graceful(guard.clone()), + args.target, + concurrency, + args.proxy, + ) + .context("create HTTP(S) client")?; + tracing::info!( %target_rps, %burst_size, @@ -212,7 +216,16 @@ pub async fn exec( }; let req_start = Instant::now(); - let result = client.serve(req).await; + let result = match client.serve(req).await { + Err(err) => Err(err), + Ok(resp) => { + let (parts, body) = resp.into_parts(); + match body.collect().await.context("collect resp payload") { + Err(err) => Err(err), + Ok(_) => Ok(parts), + } + } + }; if let Err(err) = result_tx .send(ClientResult { result, @@ -230,7 +243,7 @@ pub async fn exec( } struct ClientResult { - result: Result, + result: Result, req_start: Instant, phase: Phase, iteration: usize, @@ -269,7 +282,7 @@ async fn report_worker( let outcome = match result { Ok(resp) => { - let status = resp.status().as_u16(); + let status = resp.status.as_u16(); if (200..400).contains(&status) { RequestOutcome { ok: true, diff --git a/proxy_netbench/src/config/scenario.rs b/proxy_netbench/src/config/scenario.rs index a78233f0..3692cb11 100644 --- a/proxy_netbench/src/config/scenario.rs +++ b/proxy_netbench/src/config/scenario.rs @@ -24,15 +24,15 @@ impl Scenario { /// Construct the concrete client configuration /// associated with this scenario. pub fn client_config(self) -> ClientConfig { + let concurrency = utils::env::compute_concurrent_request_count() as u32; match self { Scenario::Baseline => { // Smooth request generation with no randomness. - let concurrency = utils::env::compute_concurrent_request_count() as u32; ClientConfig { - target_rps: Some(concurrency), + target_rps: Some(5_000), concurrency: Some(concurrency), jitter: None, - burst_size: Some(1), + burst_size: Some(100), } } @@ -40,20 +40,20 @@ impl Scenario { // Requests are sent at an uneven pace. // This introduces burstiness and queue formation. ClientConfig { - target_rps: Some(500), - concurrency: Some(100), + target_rps: Some(2_000), + concurrency: Some(concurrency * 2), jitter: Some(0.005), - burst_size: Some(2), + burst_size: Some(50), } } Scenario::FlakyUpstream => { // Client side jitter is higher to simulate unstable producers. ClientConfig { - target_rps: Some(250), - concurrency: Some(50), + target_rps: Some(1_000), + concurrency: Some(concurrency * 2), jitter: Some(0.01), - burst_size: Some(2), + burst_size: Some(20), } } } diff --git a/proxy_netbench/src/definitions.rs b/proxy_netbench/src/definitions.rs new file mode 100644 index 00000000..2649cf05 --- /dev/null +++ b/proxy_netbench/src/definitions.rs @@ -0,0 +1,5 @@ +use rama::net::address::Domain; + +// used as a pseudo-domain for gathering reports +pub const FAKE_AIKIDO_REPORTER_DOMAIN: Domain = + Domain::from_static("reporter.fake-aikido.internal"); diff --git a/proxy_netbench/src/http/har.rs b/proxy_netbench/src/http/har.rs index c2780f35..50af345e 100644 --- a/proxy_netbench/src/http/har.rs +++ b/proxy_netbench/src/http/har.rs @@ -135,5 +135,15 @@ mod tests { ); } - const HAR_LOG_FILE_EXAMPLE: &str = r##"{"log":{"version":"1.2","creator":{"name":"WebInspector","version":"537.1"},"pages":[{"startedDateTime":"2012-08-28T05:14:24.803Z","id":"page_1","title":"http://www.igvita.com/","pageTimings":{"onContentLoad":299,"onLoad":301}}],"entries":[{"startedDateTime":"2012-08-28T05:14:24.803Z","time":121,"request":{"method":"GET","url":"http://www.igvita.com/","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"www.igvita.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},{"name":"Cache-Control","value":"max-age=0"}],"queryString":[],"cookies":[],"headersSize":678,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Tue, 28 Aug 2012 05:14:24 GMT"},{"name":"Via","value":"HTTP/1.1 GWA"},{"name":"Transfer-Encoding","value":"chunked"},{"name":"Content-Encoding","value":"gzip"},{"name":"X-XSS-Protection","value":"1; mode=block"},{"name":"X-UA-Compatible","value":"IE=Edge,chrome=1"},{"name":"X-Page-Speed","value":"50_1_cn"},{"name":"Server","value":"nginx/1.0.11"},{"name":"Vary","value":"Accept-Encoding"},{"name":"Content-Type","value":"text/html; charset=utf-8"},{"name":"Cache-Control","value":"max-age=0, no-cache"},{"name":"Expires","value":"Tue, 28 Aug 2012 05:14:24 GMT"}],"cookies":[],"content":{"size":9521,"mimeType":"text/html","compression":5896},"redirectURL":"","headersSize":379,"bodySize":3625},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":1,"wait":112,"receive":6,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.011Z","time":10,"request":{"method":"GET","url":"http://fonts.googleapis.com/css?family=Open+Sans:400,600","httpVersion":"HTTP/1.1","headers":[],"queryString":[{"name":"family","value":"Open+Sans:400,600"}],"cookies":[],"headersSize":71,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[],"cookies":[],"content":{"size":542,"mimeType":"text/css"},"redirectURL":"","headersSize":17,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":-1,"wait":-1,"receive":2,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.017Z","time":31,"request":{"method":"GET","url":"http://1-ps.googleusercontent.com/h/www.igvita.com/css/style.css.pagespeed.ce.LzjUDNB25e.css","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"If-Modified-Since","value":"Mon, 27 Aug 2012 15:28:34 GMT"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"1-ps.googleusercontent.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"text/css,*/*;q=0.1"},{"name":"Cache-Control","value":"max-age=0"},{"name":"If-None-Match","value":"W/0"},{"name":"Referer","value":"http://www.igvita.com/"}],"queryString":[],"cookies":[],"headersSize":539,"bodySize":0},"response":{"status":304,"statusText":"Not Modified","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Mon, 27 Aug 2012 06:01:49 GMT"},{"name":"Age","value":"83556"},{"name":"Server","value":"GFE/2.0"},{"name":"ETag","value":"W/0"},{"name":"Expires","value":"Tue, 27 Aug 2013 06:01:49 GMT"}],"cookies":[],"content":{"size":14679,"mimeType":"text/css"},"redirectURL":"","headersSize":146,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":1,"wait":24,"receive":2,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.021Z","time":30,"request":{"method":"GET","url":"http://1-ps.googleusercontent.com/h/www.igvita.com/js/libs/modernizr.84728.js.pagespeed.jm._DgXLhVY42.js","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"If-Modified-Since","value":"Sat, 25 Aug 2012 14:30:37 GMT"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"1-ps.googleusercontent.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"*/*"},{"name":"Cache-Control","value":"max-age=0"},{"name":"If-None-Match","value":"W/0"},{"name":"Referer","value":"http://www.igvita.com/"}],"queryString":[],"cookies":[],"headersSize":536,"bodySize":0},"response":{"status":304,"statusText":"Not Modified","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Sat, 25 Aug 2012 14:30:37 GMT"},{"name":"Age","value":"225828"},{"name":"Server","value":"GFE/2.0"},{"name":"ETag","value":"W/0"},{"name":"Expires","value":"Sun, 25 Aug 2013 14:30:37 GMT"}],"cookies":[],"content":{"size":11831,"mimeType":"text/javascript"},"redirectURL":"","headersSize":147,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":0,"send":1,"wait":27,"receive":1,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.103Z","time":0,"request":{"method":"GET","url":"http://www.google-analytics.com/ga.js","httpVersion":"HTTP/1.1","headers":[],"queryString":[],"cookies":[],"headersSize":52,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Mon, 27 Aug 2012 21:57:00 GMT"},{"name":"Content-Encoding","value":"gzip"},{"name":"X-Content-Type-Options","value":"nosniff, nosniff"},{"name":"Age","value":"23052"},{"name":"Last-Modified","value":"Thu, 16 Aug 2012 07:05:05 GMT"},{"name":"Server","value":"GFE/2.0"},{"name":"Vary","value":"Accept-Encoding"},{"name":"Content-Type","value":"text/javascript"},{"name":"Expires","value":"Tue, 28 Aug 2012 09:57:00 GMT"},{"name":"Cache-Control","value":"max-age=43200, public"},{"name":"Content-Length","value":"14804"}],"cookies":[],"content":{"size":36893,"mimeType":"text/javascript"},"redirectURL":"","headersSize":17,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":-1,"wait":-1,"receive":0,"ssl":-1},"pageref":"page_1"},{"startedDateTime":"2012-08-28T05:14:25.123Z","time":91,"request":{"method":"GET","url":"http://1-ps.googleusercontent.com/beacon?org=50_1_cn&ets=load:93&ifr=0&hft=32&url=http%3A%2F%2Fwww.igvita.com%2F","httpVersion":"HTTP/1.1","headers":[{"name":"Accept-Encoding","value":"gzip,deflate,sdch"},{"name":"Accept-Language","value":"en-US,en;q=0.8"},{"name":"Connection","value":"keep-alive"},{"name":"Accept-Charset","value":"ISO-8859-1,utf-8;q=0.7,*;q=0.3"},{"name":"Host","value":"1-ps.googleusercontent.com"},{"name":"User-Agent","value":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_4) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.82 Safari/537.1"},{"name":"Accept","value":"*/*"},{"name":"Referer","value":"http://www.igvita.com/"}],"queryString":[{"name":"org","value":"50_1_cn"},{"name":"ets","value":"load:93"},{"name":"ifr","value":"0"},{"name":"hft","value":"32"},{"name":"url","value":"http%3A%2F%2Fwww.igvita.com%2F"}],"cookies":[],"headersSize":448,"bodySize":0},"response":{"status":204,"statusText":"No Content","httpVersion":"HTTP/1.1","headers":[{"name":"Date","value":"Tue, 28 Aug 2012 05:14:25 GMT"},{"name":"Content-Length","value":"0"},{"name":"X-XSS-Protection","value":"1; mode=block"},{"name":"Server","value":"PagespeedRewriteProxy 0.1"},{"name":"Content-Type","value":"text/plain"},{"name":"Cache-Control","value":"no-cache"}],"cookies":[],"content":{"size":0,"mimeType":"text/plain","compression":0},"redirectURL":"","headersSize":202,"bodySize":0},"cache":{},"timings":{"blocked":0,"dns":-1,"connect":-1,"send":0,"wait":70,"receive":7,"ssl":-1},"pageref":"page_1"}]}}"##; + #[tokio::test] + async fn test_load_har_entries_from_synthetic_file() { + let log_file = serde_json::from_str(HAR_LOG_FILE_SYNETHETIC_EXAMPLE).unwrap(); + let har_entries = har_log_file_as_har_entry_vec(log_file).await.unwrap(); + + assert_eq!(240, har_entries.len()); + } + + const HAR_LOG_FILE_EXAMPLE: &str = include_str!("../../har_files/mini_toy.har.json"); + const HAR_LOG_FILE_SYNETHETIC_EXAMPLE: &str = + include_str!("../../har_files/synthetic.har.json"); } diff --git a/proxy_netbench/src/main.rs b/proxy_netbench/src/main.rs index 3eb62645..b02d6619 100644 --- a/proxy_netbench/src/main.rs +++ b/proxy_netbench/src/main.rs @@ -14,6 +14,7 @@ use safechain_proxy_lib::utils; pub mod cmd; pub mod config; +pub mod definitions; pub mod http; #[cfg(target_family = "unix")] From dd86092a436215c10e0d553753ee84951db76ff0 Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 30 Jan 2026 12:38:07 +0100 Subject: [PATCH 36/52] ensure reporter is hookedup in mock benching --- proxy_netbench/run.py | 80 ++++++++++++++++++- .../mock/{fake_reporter.rs => fake_svc.rs} | 14 ++-- proxy_netbench/src/cmd/mock/mod.rs | 8 +- proxy_netbench/src/cmd/proxy/mod.rs | 15 +--- proxy_netbench/src/cmd/run/client.rs | 23 +----- proxy_netbench/src/definitions.rs | 5 -- proxy_netbench/src/main.rs | 1 - 7 files changed, 97 insertions(+), 49 deletions(-) rename proxy_netbench/src/cmd/mock/{fake_reporter.rs => fake_svc.rs} (68%) delete mode 100644 proxy_netbench/src/definitions.rs diff --git a/proxy_netbench/run.py b/proxy_netbench/run.py index 02b44365..bcab41af 100755 --- a/proxy_netbench/run.py +++ b/proxy_netbench/run.py @@ -4,10 +4,12 @@ import argparse import atexit +import http.client import json import os import shutil import signal +import socket import subprocess import sys import tempfile @@ -375,6 +377,59 @@ def format_delta(cur: float, prev: float, is_rate: bool = False) -> str: return f"{cur:.2f} ({d:+.2f})" +def split_host_port(addr: str) -> Tuple[str, int]: + # addr looks like "127.0.0.1:57777" + host, port_s = addr.rsplit(":", 1) + return host, int(port_s) + + +def fetch_blocked_events_count_via_mock( + mock_addr: str, timeout_s: float = 2.0 +) -> Optional[int]: + """ + Query the mock server for blocked event count. + + The endpoint is served by the mock server under a pseudo domain: + GET https://reporter-fake-aikido.internal/counter/blocked-events + + We connect to the mock server socket address, but we send Host header + for reporter-fake-aikido.internal so the mock server routes it correctly. + """ + host, port = split_host_port(mock_addr) + + conn = http.client.HTTPConnection(host, port, timeout=timeout_s) + try: + conn.request( + "GET", + "/reporter/counter/blocked-events", + headers={ + "Host": "localhost", + "Accept": "text/plain", + "Connection": "close", + }, + ) + resp = conn.getresponse() + body = resp.read().decode("utf-8", errors="replace").strip() + + if resp.status < 200 or resp.status >= 300: + eprint("blocked-events: unexpected status", resp.status, body[:200]) + return None + + try: + return int(body) + except ValueError: + eprint("blocked-events: non-int payload", body[:200]) + return None + except (OSError, http.client.HTTPException, socket.timeout) as exc: + eprint("blocked-events: request failed", exc) + return None + finally: + try: + conn.close() + except Exception: + pass + + def print_comparison_section( current: Dict[str, float], previous: Dict[str, float] ) -> None: @@ -458,7 +513,7 @@ def main() -> int: run_summary = data_dir / "run.summary.txt" env = dict(os.environ) - env.setdefault("RUST_LOG", "debug" if args.verbose else "info") + env.setdefault("RUST_LOG", "trace" if args.verbose else "info") procs: List[Proc] = [] keep_data = bool(args.report_file) @@ -618,6 +673,14 @@ def cleanup(keep: bool) -> None: # Compute metrics for comparison and saving current_metrics = compute_aggregate_from_events(events) + blocked_count: Optional[int] = None + if proxy_is_enabled: + blocked_count = fetch_blocked_events_count_via_mock(mock_addr) + if blocked_count is not None: + eprint(f"proxy: blocked_events_total={blocked_count}") + else: + eprint("proxy: blocked_events_total=unknown") + if args.save_baseline: write_kv_baseline(Path(args.save_baseline), current_metrics) @@ -651,6 +714,15 @@ def cleanup(keep: bool) -> None: if args.proxy_enabled: print(str(proxy_log)) print(str(run_log)) + + if proxy_is_enabled: + if blocked_count is not None: + print() + print(f"blocked_events_total={blocked_count}") + else: + print() + print("blocked_events_total=unknown") + return 0 # Human output @@ -660,6 +732,12 @@ def cleanup(keep: bool) -> None: print( f"mode=har har={har_path} emulate={'yes' if args.emulate else 'no'} proxy={args.proxy}" ) + if proxy_is_enabled: + if blocked_count is not None: + print(f"blocked_events_total={blocked_count}") + else: + print("blocked_events_total=unknown") + print() else: print(f"mode=scenario scenario={args.scenario} proxied={args.proxy}") print() diff --git a/proxy_netbench/src/cmd/mock/fake_reporter.rs b/proxy_netbench/src/cmd/mock/fake_svc.rs similarity index 68% rename from proxy_netbench/src/cmd/mock/fake_reporter.rs rename to proxy_netbench/src/cmd/mock/fake_svc.rs index 7340a4b8..08b83c02 100644 --- a/proxy_netbench/src/cmd/mock/fake_reporter.rs +++ b/proxy_netbench/src/cmd/mock/fake_svc.rs @@ -18,12 +18,14 @@ use rama::{ }, }; -pub(super) fn fake_reporter_svc() --> impl Service + Clone { +pub(super) fn fake_svc() -> impl Service + Clone { Arc::new( Router::new_with_state(TotalCounters::default()) - .with_post("/blocked-events", blocked_events) - .with_get("/counter/blocked-events", blocked_events_counter), + .with_post("/reporter/blocked-events", reporter_blocked_events) + .with_get( + "/reporter/counter/blocked-events", + reporter_blocked_events_counter, + ), ) } @@ -32,7 +34,7 @@ struct TotalCounters { blocked_events: Arc, } -async fn blocked_events( +async fn reporter_blocked_events( State(TotalCounters { blocked_events }): State, Json(_): Json, ) -> impl IntoResponse { @@ -40,7 +42,7 @@ async fn blocked_events( StatusCode::OK } -async fn blocked_events_counter( +async fn reporter_blocked_events_counter( State(TotalCounters { blocked_events }): State, ) -> impl IntoResponse { blocked_events.load(Ordering::Acquire).to_string() diff --git a/proxy_netbench/src/cmd/mock/mod.rs b/proxy_netbench/src/cmd/mock/mod.rs index af06c656..ebe21a60 100644 --- a/proxy_netbench/src/cmd/mock/mod.rs +++ b/proxy_netbench/src/cmd/mock/mod.rs @@ -19,6 +19,7 @@ use rama::{ }, layer::{AbortableLayer, HijackLayer, TimeoutLayer, abort::AbortController}, net::{ + address::Domain, socket::Interface, tls::{ self, ApplicationProtocol, @@ -38,7 +39,6 @@ use safechain_proxy_lib::{ use crate::{ config::{Scenario, ServerConfig}, - definitions, http::{ MockReplayIndex, MockResponseRandomIndex, har::{self, HarEntry}, @@ -46,7 +46,7 @@ use crate::{ }, }; -mod fake_reporter; +mod fake_svc; #[derive(Debug, Clone, Args)] /// run bench mock server @@ -96,8 +96,8 @@ pub async fn exec( AddRequiredResponseHeadersLayer::new() .with_server_header_value(HeaderValue::from_static(utils::env::server_identifier())), HijackLayer::new( - DomainMatcher::exact(definitions::FAKE_AIKIDO_REPORTER_DOMAIN), - self::fake_reporter::fake_reporter_svc(), + DomainMatcher::exact(Domain::tld_localhost()), + self::fake_svc::fake_svc(), ), ) .into_layer(Arc::new( diff --git a/proxy_netbench/src/cmd/proxy/mod.rs b/proxy_netbench/src/cmd/proxy/mod.rs index 6e294666..68182d21 100644 --- a/proxy_netbench/src/cmd/proxy/mod.rs +++ b/proxy_netbench/src/cmd/proxy/mod.rs @@ -14,8 +14,6 @@ use rama::{ use clap::Args; use safechain_proxy_lib::{client, diagnostics, firewall, server, storage, tls}; -use crate::definitions; - #[derive(Debug, Clone, Args)] /// run proxy in function of benchmarker pub struct ProxyCommand { @@ -75,16 +73,9 @@ pub async fn exec( // by default take into account reporting in benchmarks, // as it will usually be enabled, so we might as well see it in the numbers - let maybe_reporting_endpoint: Option = (!args.disable_reporting) - .then(|| { - format!( - "https://{}/blocked-events", - definitions::FAKE_AIKIDO_REPORTER_DOMAIN - ) - .parse() - .context("parse reporter fake domain") - }) - .transpose()?; + let maybe_reporting_endpoint: Option = (!args.disable_reporting).then_some( + Uri::from_static("https://localhost/reporter/blocked-events"), + ); // ensure to not wait for firewall creation in case shutdown was initiated, // this can happen for example in case remote lists need to be fetched and the diff --git a/proxy_netbench/src/cmd/run/client.rs b/proxy_netbench/src/cmd/run/client.rs index abe93065..cfa18a06 100644 --- a/proxy_netbench/src/cmd/run/client.rs +++ b/proxy_netbench/src/cmd/run/client.rs @@ -2,18 +2,15 @@ use std::time::Duration; use rama::{ Layer as _, Service, - error::{ErrorContext as _, OpaqueError}, + error::OpaqueError, http::{ Body, Request, Response, layer::{ - decompression::DecompressionLayer, - map_request_body::MapRequestBodyLayer, + decompression::DecompressionLayer, map_request_body::MapRequestBodyLayer, map_response_body::MapResponseBodyLayer, - retry::{ManagedPolicy, RetryLayer}, - timeout::TimeoutLayer, }, }, - layer::{AddInputExtensionLayer, MapErrLayer}, + layer::AddInputExtensionLayer, net::{ Protocol, address::{ProxyAddress, SocketAddress}, @@ -21,7 +18,6 @@ use rama::{ }, rt::Executor, service::BoxService, - utils::{backoff::ExponentialBackoff, rng::HasherRng}, }; use safechain_proxy_lib::client::{ @@ -71,19 +67,6 @@ where { Ok(( MapResponseBodyLayer::new(Body::new), - MapErrLayer::new(OpaqueError::from_std), - TimeoutLayer::new(Duration::from_secs(3)), - RetryLayer::new( - ManagedPolicy::default().with_backoff( - ExponentialBackoff::new( - Duration::from_millis(100), - Duration::from_secs(2), - 0.01, - HasherRng::default, - ) - .context("create exponential backoff impl")?, - ), - ), DecompressionLayer::new(), MapRequestBodyLayer::new(Body::new), ) diff --git a/proxy_netbench/src/definitions.rs b/proxy_netbench/src/definitions.rs deleted file mode 100644 index 2649cf05..00000000 --- a/proxy_netbench/src/definitions.rs +++ /dev/null @@ -1,5 +0,0 @@ -use rama::net::address::Domain; - -// used as a pseudo-domain for gathering reports -pub const FAKE_AIKIDO_REPORTER_DOMAIN: Domain = - Domain::from_static("reporter.fake-aikido.internal"); diff --git a/proxy_netbench/src/main.rs b/proxy_netbench/src/main.rs index b02d6619..3eb62645 100644 --- a/proxy_netbench/src/main.rs +++ b/proxy_netbench/src/main.rs @@ -14,7 +14,6 @@ use safechain_proxy_lib::utils; pub mod cmd; pub mod config; -pub mod definitions; pub mod http; #[cfg(target_family = "unix")] From 163c6a1ce81923dbf1821e093f8803ecab0b6ee3 Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 30 Jan 2026 12:40:01 +0100 Subject: [PATCH 37/52] fix none=>direct for proxy-benchmark.yml (renamed it in some previous commit) --- .github/workflows/proxy-benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/proxy-benchmark.yml b/.github/workflows/proxy-benchmark.yml index 67acdfe9..a8445cac 100644 --- a/.github/workflows/proxy-benchmark.yml +++ b/.github/workflows/proxy-benchmark.yml @@ -28,7 +28,7 @@ jobs: fail-fast: false matrix: scenario: [baseline, latency-jitter, flaky-upstream] - proxy: [none, global, scoped] + proxy: [direct, global, scoped] steps: - name: Checkout From f165653e9160ff9980eb0ef15ea1ef2849ea9a53 Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 30 Jan 2026 12:50:34 +0100 Subject: [PATCH 38/52] fix typo in python script (argh) --- proxy_netbench/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proxy_netbench/run.py b/proxy_netbench/run.py index bcab41af..c6349fee 100755 --- a/proxy_netbench/run.py +++ b/proxy_netbench/run.py @@ -711,7 +711,7 @@ def cleanup(keep: bool) -> None: print() print("logs") print(str(mock_log)) - if args.proxy_enabled: + if proxy_is_enabled: print(str(proxy_log)) print(str(run_log)) From c2fabd2d54a0ed574955543e566eb62bf3156ef4 Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 30 Jan 2026 22:13:07 +0100 Subject: [PATCH 39/52] improve default scenario values (bench) --- proxy_netbench/src/config/scenario.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/proxy_netbench/src/config/scenario.rs b/proxy_netbench/src/config/scenario.rs index 3692cb11..3bd181f2 100644 --- a/proxy_netbench/src/config/scenario.rs +++ b/proxy_netbench/src/config/scenario.rs @@ -29,10 +29,10 @@ impl Scenario { Scenario::Baseline => { // Smooth request generation with no randomness. ClientConfig { - target_rps: Some(5_000), + target_rps: Some(concurrency * 5), concurrency: Some(concurrency), jitter: None, - burst_size: Some(100), + burst_size: Some(5), } } @@ -40,20 +40,20 @@ impl Scenario { // Requests are sent at an uneven pace. // This introduces burstiness and queue formation. ClientConfig { - target_rps: Some(2_000), + target_rps: Some(concurrency * 8), concurrency: Some(concurrency * 2), jitter: Some(0.005), - burst_size: Some(50), + burst_size: Some(2), } } Scenario::FlakyUpstream => { // Client side jitter is higher to simulate unstable producers. ClientConfig { - target_rps: Some(1_000), + target_rps: Some(concurrency * 2), concurrency: Some(concurrency * 2), jitter: Some(0.01), - burst_size: Some(20), + burst_size: Some(1), } } } From ffeef09d96a0da0d401b2fd4ba3a50f3b470feaa Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 30 Jan 2026 22:19:55 +0100 Subject: [PATCH 40/52] support also direct check for har replay in CI --- .github/workflows/proxy-benchmark.yml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/proxy-benchmark.yml b/.github/workflows/proxy-benchmark.yml index a8445cac..f8035936 100644 --- a/.github/workflows/proxy-benchmark.yml +++ b/.github/workflows/proxy-benchmark.yml @@ -100,7 +100,7 @@ jobs: if-no-files-found: error bench-replay: - name: bench replay (${{ matrix.name }}) + name: bench replay (${{ matrix.name }}), proxy=${{ matrix.proxy }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -108,6 +108,10 @@ jobs: include: - name: synthetic har_fp: proxy_netbench/har_files/synthetic.har.json + proxy: direct + - name: synthetic + har_fp: proxy_netbench/har_files/synthetic.har.json + proxy: global steps: - name: Checkout @@ -146,15 +150,16 @@ jobs: - name: Run netbench (matrix) env: + PROXY: ${{ matrix.proxy }} REPLAY_NAME: ${{ matrix.name }} REPLAY_HAR_FP: ${{ matrix.har_fp }} run: | set -euo pipefail mkdir -p reports "$RUN_DIR" - CUR_KV="$RUN_DIR/replay_${REPLAY_NAME}.global.kv.txt" - BASE_KV="$BASELINE_DIR/replay_${REPLAY_NAME}.global.kv.txt" - REPORT_PATH="reports/replay_${REPLAY_NAME}.global.jsonl" + CUR_KV="$RUN_DIR/replay_${REPLAY_NAME}.${PROXY}.kv.txt" + BASE_KV="$BASELINE_DIR/replay_${REPLAY_NAME}.${PROXY}.kv.txt" + REPORT_PATH="reports/replay_${REPLAY_NAME}.${PROXY}.jsonl" COMPARE_FLAG="" if [ -f "$BASE_KV" ]; then @@ -162,7 +167,7 @@ jobs: fi python3 "$NETBENCH_ORCHESTRATOR" \ - --proxy "global" \ + --proxy "$PROXY" \ --har "$REPLAY_HAR_FP" \ --emulate \ --report-file "$REPORT_PATH" \ @@ -172,11 +177,11 @@ jobs: - name: Upload run artifacts (jsonl, summary, kv) uses: actions/upload-artifact@v4 with: - name: netbench-replay_${{ matrix.name }}-proxy-global + name: netbench-replay_${{ matrix.name }}-proxy-${{ matrix.proxy }} path: | - reports/replay_${{ matrix.name }}.global.jsonl - reports/replay_${{ matrix.name }}.global.jsonl.summary.txt - run-metrics/replay_${{ matrix.name }}.global.kv.txt + reports/replay_${{ matrix.name }}.${{ matrix.proxy }}.jsonl + reports/replay_${{ matrix.name }}.${{ matrix.proxy }}.jsonl.summary.txt + run-metrics/replay_${{ matrix.name }}.${{ matrix.proxy }}.kv.txt if-no-files-found: error publish_baselines: From 3ec46c4118db1b08170912ef419b2d8c00676f3d Mon Sep 17 00:00:00 2001 From: glendc Date: Fri, 30 Jan 2026 22:55:49 +0100 Subject: [PATCH 41/52] refactor mock code so it can also be used elsewhere --- .../requests/source/{mock/mod.rs => mock.rs} | 44 ++++++++++--------- proxy_netbench/src/main.rs | 1 + proxy_netbench/src/mock/mod.rs | 28 ++++++++++++ .../run/requests/source => }/mock/pypi.rs | 27 +++++++++--- .../source/mock/none.rs => mock/random.rs} | 31 ++++++++++++- .../run/requests/source => }/mock/vscode.rs | 29 ++++++++---- 6 files changed, 123 insertions(+), 37 deletions(-) rename proxy_netbench/src/cmd/run/requests/source/{mock/mod.rs => mock.rs} (69%) create mode 100644 proxy_netbench/src/mock/mod.rs rename proxy_netbench/src/{cmd/run/requests/source => }/mock/pypi.rs (74%) rename proxy_netbench/src/{cmd/run/requests/source/mock/none.rs => mock/random.rs} (51%) rename proxy_netbench/src/{cmd/run/requests/source => }/mock/vscode.rs (79%) diff --git a/proxy_netbench/src/cmd/run/requests/source/mock/mod.rs b/proxy_netbench/src/cmd/run/requests/source/mock.rs similarity index 69% rename from proxy_netbench/src/cmd/run/requests/source/mock/mod.rs rename to proxy_netbench/src/cmd/run/requests/source/mock.rs index 844d8c35..02c29fea 100644 --- a/proxy_netbench/src/cmd/run/requests/source/mock/mod.rs +++ b/proxy_netbench/src/cmd/run/requests/source/mock.rs @@ -1,18 +1,16 @@ use std::collections::VecDeque; -use rama::{ - error::OpaqueError, - http::{Body, Request}, - telemetry::tracing, -}; +use rama::{error::OpaqueError, http::Request, telemetry::tracing}; use rand::distr::{Distribution as _, weighted::WeightedIndex}; use safechain_proxy_lib::storage; -use crate::config::{Product, ProductValues, default_product_values}; - -mod none; -mod pypi; -mod vscode; +use crate::{ + config::{Product, ProductValues, default_product_values}, + mock::{ + MockRequestParameters, RequestMocker, pypi::PyPIMocker, random::RandomMocker, + vscode::VSCodeMocker, + }, +}; /// Generate N random requests for a M iterations + warmup pub async fn rand_requests( @@ -33,8 +31,9 @@ pub async fn rand_requests( .join(", ") ); - let mut vscode = self::vscode::VSCodeUriGenerator::new(sync_storage.clone()); - let mut pypi = self::pypi::PyPIUriGenerator::new(sync_storage); + let mut rnd = RandomMocker::new(); + let mut vscode = VSCodeMocker::new(sync_storage.clone()); + let mut pypi = PyPIMocker::new(sync_storage); let mut total_requests = Vec::with_capacity(iterations); for i in 1..=iterations { @@ -46,6 +45,7 @@ pub async fn rand_requests( request_count, &products, malware_ratio, + &mut rnd, &mut vscode, &mut pypi, ) @@ -58,6 +58,7 @@ pub async fn rand_requests( request_count_warmup, &products, malware_ratio, + &mut rnd, &mut vscode, &mut pypi, ) @@ -71,8 +72,9 @@ async fn rand_requests_inner( request_count: usize, products: &ProductValues, malware_ratio: f64, - vscode: &mut self::vscode::VSCodeUriGenerator, - pypi: &mut self::pypi::PyPIUriGenerator, + rnd: &mut RandomMocker, + vscode: &mut VSCodeMocker, + pypi: &mut PyPIMocker, ) -> Result, OpaqueError> { let mut requests = VecDeque::with_capacity(request_count); @@ -81,15 +83,15 @@ async fn rand_requests_inner( for _ in 0..request_count { let product = products[dist.sample(&mut rand::rng())].value.clone(); - let uri = match product { - Product::None | Product::Unknown(_) => self::none::random_uri()?, - Product::VSCode => vscode.random_uri(malware_ratio).await?, - Product::PyPI => pypi.random_uri(malware_ratio).await?, + let params = MockRequestParameters { malware_ratio }; + + let req = match product { + Product::None | Product::Unknown(_) => rnd.mock_request(params).await?, + Product::VSCode => vscode.mock_request(params).await?, + Product::PyPI => pypi.mock_request(params).await?, }; - let mut req = Request::new(Body::empty()); - *req.uri_mut() = uri; - requests.push_back(req); + requests.push_back(req) } Ok(requests) diff --git a/proxy_netbench/src/main.rs b/proxy_netbench/src/main.rs index 3eb62645..abe72c3d 100644 --- a/proxy_netbench/src/main.rs +++ b/proxy_netbench/src/main.rs @@ -15,6 +15,7 @@ use safechain_proxy_lib::utils; pub mod cmd; pub mod config; pub mod http; +pub mod mock; #[cfg(target_family = "unix")] #[global_allocator] diff --git a/proxy_netbench/src/mock/mod.rs b/proxy_netbench/src/mock/mod.rs new file mode 100644 index 00000000..fe0b0974 --- /dev/null +++ b/proxy_netbench/src/mock/mod.rs @@ -0,0 +1,28 @@ +// Generate mock data such as fake requests for a product. + +use rama::http::Request; + +pub mod pypi; +pub mod random; +pub mod vscode; + +#[derive(Debug)] +pub struct MockRequestParameters { + pub malware_ratio: f64, +} + +impl Default for MockRequestParameters { + fn default() -> Self { + Self { malware_ratio: 0.1 } + } +} + +pub trait RequestMocker: Send + Sync + 'static { + /// The type of error returned by the request mocker. + type Error: Send + 'static; + + fn mock_request( + &mut self, + params: MockRequestParameters, + ) -> impl Future> + Send + '_; +} diff --git a/proxy_netbench/src/cmd/run/requests/source/mock/pypi.rs b/proxy_netbench/src/mock/pypi.rs similarity index 74% rename from proxy_netbench/src/cmd/run/requests/source/mock/pypi.rs rename to proxy_netbench/src/mock/pypi.rs index 95ad3f0c..134c6040 100644 --- a/proxy_netbench/src/cmd/run/requests/source/mock/pypi.rs +++ b/proxy_netbench/src/mock/pypi.rs @@ -1,6 +1,6 @@ use rama::{ error::{ErrorContext as _, OpaqueError}, - http::Uri, + http::{Body, Request, Uri}, }; use rand::{rng, seq::IndexedRandom as _}; @@ -10,23 +10,23 @@ use safechain_proxy_lib::{ storage::SyncCompactDataStorage, }; -use crate::http::malware::download_malware_list_for_uri; +use crate::{http::malware::download_malware_list_for_uri, mock::RequestMocker}; #[derive(Debug)] -pub(super) struct PyPIUriGenerator { +pub struct PyPIMocker { storage: Option, malware_list: Vec, } -impl PyPIUriGenerator { - pub(super) fn new(storage: SyncCompactDataStorage) -> Self { +impl PyPIMocker { + pub fn new(storage: SyncCompactDataStorage) -> Self { Self { storage: Some(storage), malware_list: Default::default(), } } - pub(super) async fn random_uri(&mut self, malware_ratio: f64) -> Result { + async fn random_uri(&mut self, malware_ratio: f64) -> Result { if let Some(storage) = self.storage.take() { self.malware_list = download_malware_list_for_uri(storage, MALWARE_LIST_URI_STR_PYPI) .await @@ -66,3 +66,18 @@ impl PyPIUriGenerator { } } } + +impl RequestMocker for PyPIMocker { + type Error = OpaqueError; + + async fn mock_request( + &mut self, + params: super::MockRequestParameters, + ) -> Result { + let uri = self.random_uri(params.malware_ratio).await?; + + let mut req = Request::new(Body::empty()); + *req.uri_mut() = uri; + Ok(req) + } +} diff --git a/proxy_netbench/src/cmd/run/requests/source/mock/none.rs b/proxy_netbench/src/mock/random.rs similarity index 51% rename from proxy_netbench/src/cmd/run/requests/source/mock/none.rs rename to proxy_netbench/src/mock/random.rs index 6d8d7f15..87a8c5f9 100644 --- a/proxy_netbench/src/cmd/run/requests/source/mock/none.rs +++ b/proxy_netbench/src/mock/random.rs @@ -1,13 +1,40 @@ use rama::{ error::{ErrorContext as _, OpaqueError}, - http::Uri, + http::{Body, Request, Uri}, }; use rand::{rng, seq::IndexedRandom as _}; use safechain_proxy_lib::firewall::malware_list::MALWARE_LIST_URI_STR_PYPI; -pub(super) fn random_uri() -> Result { +use crate::mock::{MockRequestParameters, RequestMocker}; + +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct RandomMocker; + +impl RandomMocker { + pub fn new() -> Self { + Self + } +} + +impl RequestMocker for RandomMocker { + type Error = OpaqueError; + + async fn mock_request( + &mut self, + _params: MockRequestParameters, + ) -> Result { + let uri = random_uri()?; + + let mut req = Request::new(Body::empty()); + *req.uri_mut() = uri; + Ok(req) + } +} + +fn random_uri() -> Result { Ok(Uri::from_static( [ "http://example.com", diff --git a/proxy_netbench/src/cmd/run/requests/source/mock/vscode.rs b/proxy_netbench/src/mock/vscode.rs similarity index 79% rename from proxy_netbench/src/cmd/run/requests/source/mock/vscode.rs rename to proxy_netbench/src/mock/vscode.rs index 0f2f7137..77d881e6 100644 --- a/proxy_netbench/src/cmd/run/requests/source/mock/vscode.rs +++ b/proxy_netbench/src/mock/vscode.rs @@ -1,6 +1,6 @@ use rama::{ error::{ErrorContext as _, OpaqueError}, - http::Uri, + http::{Body, Request, Uri}, }; use rand::{rng, seq::IndexedRandom as _}; @@ -10,23 +10,23 @@ use safechain_proxy_lib::{ storage::SyncCompactDataStorage, }; -use crate::http::malware::download_malware_list_for_uri; +use crate::{http::malware::download_malware_list_for_uri, mock::RequestMocker}; #[derive(Debug)] -pub(super) struct VSCodeUriGenerator { +pub struct VSCodeMocker { storage: Option, malware_list: Vec, } -impl VSCodeUriGenerator { - pub(super) fn new(storage: SyncCompactDataStorage) -> Self { +impl VSCodeMocker { + pub fn new(storage: SyncCompactDataStorage) -> Self { Self { storage: Some(storage), malware_list: Default::default(), } } - pub(super) async fn random_uri(&mut self, malware_ratio: f64) -> Result { + async fn random_uri(&mut self, malware_ratio: f64) -> Result { if let Some(storage) = self.storage.take() { self.malware_list = download_malware_list_for_uri(storage, MALWARE_LIST_URI_STR_VSCODE) .await @@ -55,8 +55,6 @@ impl VSCodeUriGenerator { .choose(&mut rng()) .context("select random pypi path template")?; - // TODO: make this configurable via cli arg - if rand::random_bool(malware_ratio) { let entry = self .malware_list @@ -84,3 +82,18 @@ impl VSCodeUriGenerator { } } } + +impl RequestMocker for VSCodeMocker { + type Error = OpaqueError; + + async fn mock_request( + &mut self, + params: super::MockRequestParameters, + ) -> Result { + let uri = self.random_uri(params.malware_ratio).await?; + + let mut req = Request::new(Body::empty()); + *req.uri_mut() = uri; + Ok(req) + } +} From 35e64af0b6f15301e9cb6dd2020cb2c1d89ff297 Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 31 Jan 2026 01:35:47 +0100 Subject: [PATCH 42/52] support req emulation in netbench for one-off cases useful to manually/quickly test if a target is blocked, eithr directly or as a curl cmd for a remote proxy --- docs/netbench.md | 25 +++- .../src/client/mock_client/assert_endpoint.rs | 8 +- proxy/src/firewall/mod.rs | 46 ++++-- proxy/src/firewall/notifier.rs | 12 ++ proxy/src/firewall/rule/pypi.rs | 14 +- proxy_netbench/src/cmd/emulate/client.rs | 126 ++++++++++++++++ proxy_netbench/src/cmd/emulate/mod.rs | 140 ++++++++++++++++++ proxy_netbench/src/cmd/mock/fake_svc.rs | 4 +- proxy_netbench/src/cmd/mod.rs | 1 + proxy_netbench/src/main.rs | 4 + proxy_netbench/src/mock/pypi.rs | 44 ++++-- proxy_netbench/src/mock/vscode.rs | 10 +- 12 files changed, 388 insertions(+), 46 deletions(-) create mode 100644 proxy_netbench/src/cmd/emulate/client.rs create mode 100644 proxy_netbench/src/cmd/emulate/mod.rs diff --git a/docs/netbench.md b/docs/netbench.md index bccf059b..547cb534 100644 --- a/docs/netbench.md +++ b/docs/netbench.md @@ -207,25 +207,38 @@ The orchestrator is recommended, but the individual components can be run manual ### Mock server ```bash -netbench mock --scenario baseline +just run-netbench-cli mock --scenario baseline -# use `netbench mock --help` for more usage info +# use `just run-netbench-cli mock --help` for more usage info ``` ### Proxy ```bash -netbench proxy +just run-netbench-cli proxy -# use `netbench proxy --help` for more usage info +# use `just run-netbench-cli proxy --help` for more usage info ``` ### Runner ```bash -netbench run --json --scenario baseline
+just run-netbench-cli run --json --scenario baseline
-# use `netbench run --help` for more usage info +# use `just run-netbench-cli run --help` for more usage info ``` Manual usage is useful for debugging or integration with custom tooling. + +### Emulate + +The emulate command in the netbench cli allows you to +verify if the proxy firewall is correctly blocking +and notifying about malware for support apps (e.g. vscode). + +```bash +just run-netbench-cli emulate vscode +``` + +If all is well it will show both the blocked response as well as +the captured blocked-event notification. diff --git a/proxy/src/client/mock_client/assert_endpoint.rs b/proxy/src/client/mock_client/assert_endpoint.rs index 8d8cc5d3..721b8d44 100644 --- a/proxy/src/client/mock_client/assert_endpoint.rs +++ b/proxy/src/client/mock_client/assert_endpoint.rs @@ -18,11 +18,11 @@ use rama::{ utils::collections::AppendOnlyVec, }; -use crate::server::proxy::FirewallUserConfig; +use crate::{firewall::events::BlockedEvent, server::proxy::FirewallUserConfig}; #[derive(Clone, Debug)] pub struct MockState { - pub blocked_events: Arc>>, + pub blocked_events: Arc>>, } impl MockState { @@ -65,9 +65,9 @@ async fn safechain_config_echo(req: Request) -> impl IntoResponse { async fn record_blocked_event( State(MockState { blocked_events }): State, - Json(value): Json, + Json(event): Json, ) -> impl IntoResponse { - blocked_events.load().push(value); + blocked_events.load().push(event); StatusCode::NO_CONTENT } diff --git a/proxy/src/firewall/mod.rs b/proxy/src/firewall/mod.rs index 39766f12..59931bb2 100644 --- a/proxy/src/firewall/mod.rs +++ b/proxy/src/firewall/mod.rs @@ -35,7 +35,7 @@ mod pac; use self::domain_matcher::DomainMatcher; -use crate::storage::SyncCompactDataStorage; +use crate::{firewall::notifier::EventNotifier, storage::SyncCompactDataStorage}; use self::rule::{RequestAction, Rule}; @@ -53,6 +53,31 @@ impl Firewall { guard: ShutdownGuard, data: SyncCompactDataStorage, reporting_endpoint: Option, + ) -> Result { + let notifier = match reporting_endpoint { + Some(endpoint) => match self::notifier::EventNotifier::try_new( + Executor::graceful(guard.clone()), + endpoint, + ) { + Ok(notifier) => Some(notifier), + Err(err) => { + tracing::warn!( + error = %err, + "failed to initialize blocked-event notifier; reporting disabled" + ); + None + } + }, + None => None, + }; + + Self::try_new_with_event_notifier(guard, data, notifier).await + } + + pub async fn try_new_with_event_notifier( + guard: ShutdownGuard, + data: SyncCompactDataStorage, + notifier: Option, ) -> Result { let exec = Executor::graceful(guard.clone()); @@ -82,20 +107,6 @@ impl Firewall { .into_layer(inner_https_client) .boxed(); - let notifier = match reporting_endpoint { - Some(endpoint) => match self::notifier::EventNotifier::try_new(exec, endpoint) { - Ok(notifier) => Some(notifier), - Err(err) => { - tracing::warn!( - error = %err, - "failed to initialize blocked-event notifier; reporting disabled" - ); - None - } - }, - None => None, - }; - Ok(Self { block_rules: Arc::new(vec![ self::rule::vscode::RuleVSCode::try_new( @@ -158,7 +169,10 @@ impl Firewall { for rule in self.block_rules.iter() { match rule.evaluate_request(mod_req).await? { - RequestAction::Allow(new_mod_req) => mod_req = new_mod_req, + RequestAction::Allow(new_mod_req) => { + tracing::trace!("firewall rule for {} allows request", rule.product_name()); + mod_req = new_mod_req + } RequestAction::Block(blocked) => { self.record_blocked_event(blocked.info.clone()).await; return Ok(RequestAction::Block(blocked)); diff --git a/proxy/src/firewall/notifier.rs b/proxy/src/firewall/notifier.rs index d69b9d79..660482af 100644 --- a/proxy/src/firewall/notifier.rs +++ b/proxy/src/firewall/notifier.rs @@ -64,6 +64,18 @@ impl EventNotifier { let client = crate::client::new_web_client(exec.clone(), crate::client::WebClientConfig::default())? .boxed(); + Self::try_new_with_client(exec, reporting_endpoint, client) + } + + pub fn try_new_with_client( + exec: Executor, + reporting_endpoint: Uri, + client: C, + ) -> Result + where + C: Service, + { + let client = client.boxed(); let limit = Arc::new(Semaphore::const_new(env::compute_concurrent_request_count())); let dedup = moka::sync::CacheBuilder::new(MAX_EVENTS) .time_to_live(EVENT_DEDUP_WINDOW) diff --git a/proxy/src/firewall/rule/pypi.rs b/proxy/src/firewall/rule/pypi.rs index 2e6e1c8f..76379df0 100644 --- a/proxy/src/firewall/rule/pypi.rs +++ b/proxy/src/firewall/rule/pypi.rs @@ -218,12 +218,17 @@ fn parse_wheel_filename(filename: &str) -> Option { let version = rest.split('-').next()?; if version.eq_ignore_ascii_case("latest") || dist.is_empty() || version.is_empty() { + tracing::debug!(version, dist, "ignore pypi wheel"); return None; } Some(PackageInfo { name: normalize_package_name(dist), - version: PackageVersion::from_str(version).unwrap(), + version: PackageVersion::from_str(version) + .inspect_err(|err| { + tracing::debug!("failed to parse package version: {err}"); + }) + .ok()?, }) } @@ -245,12 +250,17 @@ fn parse_source_dist_filename(filename: &str) -> Option { let (dist, version) = base.rsplit_once('-')?; if version.eq_ignore_ascii_case("latest") || dist.is_empty() || version.is_empty() { + tracing::debug!(version, dist, "ignore pypi dist filename"); return None; } Some(PackageInfo { name: normalize_package_name(dist), - version: PackageVersion::from_str(version).unwrap(), + version: PackageVersion::from_str(version) + .inspect_err(|err| { + tracing::debug!("failed to parse package version: {err}"); + }) + .ok()?, }) } diff --git a/proxy_netbench/src/cmd/emulate/client.rs b/proxy_netbench/src/cmd/emulate/client.rs new file mode 100644 index 00000000..a55e565e --- /dev/null +++ b/proxy_netbench/src/cmd/emulate/client.rs @@ -0,0 +1,126 @@ +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use rama::{ + Layer as _, Service, + error::{ErrorContext as _, OpaqueError}, + graceful::ShutdownGuard, + http::{ + Request, Response, StatusCode, Uri, + layer::traffic_writer::{ + BidirectionalWriter, RequestWriterLayer, ResponseWriterLayer, WriterMode, + }, + service::web::{ + IntoEndpointService, Router, + extract::{Json, State}, + response::IntoResponse, + }, + }, + layer::MapErrLayer, + rt::Executor, + service::BoxService, + utils::collections::AppendOnlyVec, +}; + +use safechain_proxy_lib::{ + firewall::{Firewall, events::BlockedEvent, notifier::EventNotifier}, + storage, +}; + +#[derive(Debug, Clone)] +pub(super) struct Client { + shared_data: Data, + web_svc: BoxService, +} + +pub(super) async fn new_client(guard: ShutdownGuard, data: PathBuf) -> Result { + let shared_data = Data::default(); + + let data_storage = + storage::SyncCompactDataStorage::try_new(data.clone()).with_context(|| { + format!( + "create compact data storage using dir at path '{}'", + data.display() + ) + })?; + + let exec = Executor::graceful(guard.clone()); + + let traffic_writer = + BidirectionalWriter::stdout_unbounded(&exec, Some(WriterMode::All), Some(WriterMode::All)); + + let notifier_web_client = Arc::new( + MapErrLayer::new(OpaqueError::from_std).into_layer( + Router::new_with_state(shared_data.clone()) + .with_post("/blocked-event", report_blocked_event), + ), + ); + let notifier = EventNotifier::try_new_with_client( + exec, + Uri::from_static("https://notifier.fake-aikido.internal/blocked-event"), + notifier_web_client, + )?; + + let firewall = + Firewall::try_new_with_event_notifier(guard, data_storage, Some(notifier)).await?; + + let web_svc = ( + MapErrLayer::new(OpaqueError::from_boxed), + RequestWriterLayer::new(traffic_writer.clone()), + ResponseWriterLayer::new(traffic_writer), + firewall.clone().into_evaluate_request_layer(), + firewall.into_evaluate_response_layer(), + ) + .into_layer( + (StatusCode::INTERNAL_SERVER_ERROR, "request was not blocked").into_endpoint_service(), + ) + .boxed(); + + Ok(Client { + shared_data, + web_svc, + }) +} + +impl Service for Client { + type Output = Response; + type Error = OpaqueError; + + #[inline(always)] + fn serve( + &self, + req: Request, + ) -> impl Future> + Send + '_ { + self.web_svc.serve(req) + } +} + +impl Client { + pub(super) async fn wait_for_blocked_events(&self) -> Result<(), OpaqueError> { + for _ in 1..10 { + if !self.shared_data.blocked_events.is_empty() { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + Err(OpaqueError::from_display( + "failed to wait for blocked events", + )) + } + + pub(super) fn blocked_events(&self) -> impl Iterator { + self.shared_data.blocked_events.iter() + } +} + +#[derive(Debug, Clone, Default)] +struct Data { + blocked_events: Arc>, +} + +async fn report_blocked_event( + State(Data { blocked_events }): State, + Json(event): Json, +) -> impl IntoResponse { + blocked_events.push(event); + StatusCode::OK +} diff --git a/proxy_netbench/src/cmd/emulate/mod.rs b/proxy_netbench/src/cmd/emulate/mod.rs new file mode 100644 index 00000000..802d2cc1 --- /dev/null +++ b/proxy_netbench/src/cmd/emulate/mod.rs @@ -0,0 +1,140 @@ +use std::{path::PathBuf, str::FromStr}; + +use rama::{ + Service, + error::{ErrorContext as _, OpaqueError}, + graceful::ShutdownGuard, + http::{Request, body::util::BodyExt, convert::curl}, + telemetry::tracing, +}; + +use clap::Args; +use safechain_proxy_lib::{firewall::version::PackageVersion, storage}; + +use crate::mock::{self, MockRequestParameters, RequestMocker}; + +mod client; + +#[derive(Debug, Clone)] +/// Product to emulate +enum Product { + VSCode, + PyPI, +} + +#[derive(Debug, Clone, Args)] +/// emulate a request that is to be blocked +pub struct EmulateCommand { + /// product to emulate (e.g. vscode, pypi, ...) + #[arg(required = true)] + product: Product, + + /// instead of emulating return a curl request + #[arg(long, default_value_t = false)] + curl: bool, +} + +pub async fn exec( + data: PathBuf, + guard: ShutdownGuard, + args: EmulateCommand, +) -> Result<(), OpaqueError> { + let req = tokio::select! { + _ = guard.cancelled() => { + return Err(OpaqueError::from_display("exit cmd while generating mock req")); + } + + result = create_mock_req(data.clone(), args.product) => { + result? + } + }; + + if args.curl { + let (parts, body) = req.into_parts(); + let bytes = body + .collect() + .await + .context("collect (mock) req payload")? + .to_bytes(); + + println!( + "{}", + curl::cmd_string_for_request_parts_and_payload(&parts, &bytes) + ); + return Ok(()); + } + + tracing::info!("emulate request: {req:?}"); + emulate_req(data, guard, req).await +} + +async fn emulate_req(data: PathBuf, guard: ShutdownGuard, req: Request) -> Result<(), OpaqueError> { + let client = self::client::new_client(guard, data).await?; + + let resp = client.serve(req).await?; + + if resp.status().is_success() { + return Err(OpaqueError::from_display(format!( + "unexpected response: {resp:?}" + ))); + } + + client.wait_for_blocked_events().await?; + for blocked_event in client.blocked_events() { + println!( + "[{}] blocked event: product={}; identifier={}; version={}", + blocked_event.ts_ms, + blocked_event.artifact.product, + blocked_event.artifact.identifier, + blocked_event + .artifact + .version + .clone() + .unwrap_or_else(|| PackageVersion::None), + ); + } + + Ok(()) +} + +async fn create_mock_req(data: PathBuf, product: Product) -> Result { + tokio::fs::create_dir_all(&data) + .await + .with_context(|| format!("create data directory at path '{}'", data.display()))?; + let data_storage = + storage::SyncCompactDataStorage::try_new(data.clone()).with_context(|| { + format!( + "create compact data storage using dir at path '{}'", + data.display() + ) + })?; + tracing::info!(path = ?data, "data directory ready to be used"); + + let mock_req_params = MockRequestParameters { malware_ratio: 1.0 }; + + match product { + Product::VSCode => mock::vscode::VSCodeMocker::new(data_storage) + .mock_request(mock_req_params) + .await + .context("mock vscode request"), + Product::PyPI => mock::pypi::PyPIMocker::new(data_storage) + .mock_request(mock_req_params) + .await + .context("mock pypi request"), + } +} + +impl FromStr for Product { + type Err = OpaqueError; + + fn from_str(s: &str) -> Result { + let trimmed_s = s.trim(); + if trimmed_s.eq_ignore_ascii_case("vscode") { + Ok(Self::VSCode) + } else if trimmed_s.eq_ignore_ascii_case("pypi") { + Ok(Self::PyPI) + } else { + Err(OpaqueError::from_display(format!("unknown variant '{s}'"))) + } + } +} diff --git a/proxy_netbench/src/cmd/mock/fake_svc.rs b/proxy_netbench/src/cmd/mock/fake_svc.rs index 08b83c02..f81f1859 100644 --- a/proxy_netbench/src/cmd/mock/fake_svc.rs +++ b/proxy_netbench/src/cmd/mock/fake_svc.rs @@ -18,6 +18,8 @@ use rama::{ }, }; +use safechain_proxy_lib::firewall::events::BlockedEvent; + pub(super) fn fake_svc() -> impl Service + Clone { Arc::new( Router::new_with_state(TotalCounters::default()) @@ -36,7 +38,7 @@ struct TotalCounters { async fn reporter_blocked_events( State(TotalCounters { blocked_events }): State, - Json(_): Json, + Json(_): Json, ) -> impl IntoResponse { let _ = blocked_events.fetch_add(1, Ordering::SeqCst); StatusCode::OK diff --git a/proxy_netbench/src/cmd/mod.rs b/proxy_netbench/src/cmd/mod.rs index 124b7590..85b02037 100644 --- a/proxy_netbench/src/cmd/mod.rs +++ b/proxy_netbench/src/cmd/mod.rs @@ -1,3 +1,4 @@ +pub mod emulate; pub mod mock; pub mod proxy; pub mod run; diff --git a/proxy_netbench/src/main.rs b/proxy_netbench/src/main.rs index abe72c3d..2208089d 100644 --- a/proxy_netbench/src/main.rs +++ b/proxy_netbench/src/main.rs @@ -73,6 +73,7 @@ pub struct Args { #[allow(clippy::large_enum_variant)] enum CliCommands { Run(self::cmd::run::RunCommand), + Emulate(self::cmd::emulate::EmulateCommand), Mock(self::cmd::mock::MockCommand), Proxy(self::cmd::proxy::ProxyCommand), } @@ -114,6 +115,9 @@ where graceful.spawn_task_fn(async move |guard| { let result = match args.cmds { CliCommands::Run(run_args) => self::cmd::run::exec(args.data, guard, run_args).await, + CliCommands::Emulate(emulate_args) => { + self::cmd::emulate::exec(args.data, guard, emulate_args).await + } CliCommands::Mock(mock_args) => { self::cmd::mock::exec(args.data, guard, mock_args).await } diff --git a/proxy_netbench/src/mock/pypi.rs b/proxy_netbench/src/mock/pypi.rs index 134c6040..e5560a86 100644 --- a/proxy_netbench/src/mock/pypi.rs +++ b/proxy_netbench/src/mock/pypi.rs @@ -33,32 +33,52 @@ impl PyPIMocker { .context("download pypi malware_list")?; } - const URI_TEMPLATES: &[&str] = &[ - "https://pypi.org/pypi//json", - "https://pypi.org/simple//", + const TARGET_URI_TEMPLATE: &[&str] = &[ "https://files.pythonhosted.org/packages/abc/def/--py3-none-any.whl", "https://files.pythonhosted.org/packages/source/d//-.tar.gz", - "https://pypi.org/pypi//json", - "https://pypi.org/", - "https://pypi.org/help/", ]; - let template = URI_TEMPLATES - .choose(&mut rng()) - .context("select random PyPI uri template")?; - if rand::random_bool(malware_ratio) { + let template = TARGET_URI_TEMPLATE + .choose(&mut rng()) + .context("select random PyPI uri template")?; + let entry = self .malware_list .choose(&mut rng()) .context("select random PyPI malware")?; + + let package_name = entry.package_name.clone(); + let normalised_package_name = if template.ends_with(".whl") { + package_name.replace("-", "_") + } else { + package_name + }; + template - .replace("", &entry.package_name) + .replace("", &normalised_package_name) .replace("", &entry.version.to_string()) .parse() .context("parse PyPI uri") } else { - template + const META_URI_TEMPLATES: &[&str] = &[ + "https://pypi.org/pypi//json", + "https://pypi.org/simple//", + "https://pypi.org/pypi//json", + "https://pypi.org/", + "https://pypi.org/help/", + ]; + + const TOTAL_URI_LEN: usize = META_URI_TEMPLATES.len() + TARGET_URI_TEMPLATE.len(); + + let idx = rand::random_range(0..TOTAL_URI_LEN); + let value = if idx < META_URI_TEMPLATES.len() { + META_URI_TEMPLATES[idx] + } else { + TARGET_URI_TEMPLATE[idx - META_URI_TEMPLATES.len()] + }; + + value .replace("", "netbench-foo") .replace("", "bar") .parse() diff --git a/proxy_netbench/src/mock/vscode.rs b/proxy_netbench/src/mock/vscode.rs index 77d881e6..f94ae3b3 100644 --- a/proxy_netbench/src/mock/vscode.rs +++ b/proxy_netbench/src/mock/vscode.rs @@ -41,11 +41,11 @@ impl VSCodeMocker { "netbench-foo.gallerycdn.vsassets.io", ]; const PATH_TEMPLATES: &[&str] = &[ - "/files////foo", - "/extensions///foo", - "/_apis/public/gallery/publishers//vsextensions//foo", - "/_apis/public/gallery/publisher///foo", - "/_apis/public/gallery/publisher//extension//foo", + "/files////foo/vspackage", + "/extensions///foo/vspackage", + "/_apis/public/gallery/publishers//vsextensions//foo/vspackage", + "/_apis/public/gallery/publisher///foo/vspackage", + "/_apis/public/gallery/publisher//extension//foo/vspackage", ]; let domain = DOMAINS From 265195d9b6c8a517ea4d03ad005dd6dfb25fbddf Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 31 Jan 2026 13:50:34 +0100 Subject: [PATCH 43/52] address feedback aikibot part 1 --- .github/workflows/proxy-benchmark.yml | 4 +- proxy/src/server/meta/mod.rs | 77 ++++--- proxy/src/server/proxy/mod.rs | 83 +++++--- proxy_netbench/run.py | 17 +- proxy_netbench/scripts/download_baselines.py | 4 + proxy_netbench/src/cmd/run/client.rs | 2 +- proxy_netbench/src/cmd/run/mod.rs | 196 ++++++++++++------ .../src/cmd/run/requests/rps_pacer.rs | 4 +- 8 files changed, 253 insertions(+), 134 deletions(-) diff --git a/.github/workflows/proxy-benchmark.yml b/.github/workflows/proxy-benchmark.yml index f8035936..e8c3a310 100644 --- a/.github/workflows/proxy-benchmark.yml +++ b/.github/workflows/proxy-benchmark.yml @@ -35,7 +35,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 - name: Setup Python uses: actions/setup-python@v5 @@ -118,7 +118,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 - name: Setup Python uses: actions/setup-python@v5 diff --git a/proxy/src/server/meta/mod.rs b/proxy/src/server/meta/mod.rs index 6e9fd6b4..341e3033 100644 --- a/proxy/src/server/meta/mod.rs +++ b/proxy/src/server/meta/mod.rs @@ -69,8 +69,52 @@ pub async fn build_meta_https_server( firewall: Firewall, #[cfg(feature = "har")] har_client: HarClient, ) -> Result + Clone>, OpaqueError> { + let http_router = build_meta_http_router( + root_ca, + proxy_addr, + firewall, + #[cfg(feature = "har")] + har_client, + ); + + let http_svc = ( + TraceLayer::new_for_http(), + AddRequiredResponseHeadersLayer::new() + .with_server_header_value(HeaderValue::from_static(crate::utils::env::project_name())), + ) + .into_layer(http_router); + + let http_server = HttpServer::auto(exec.clone()).service(Arc::new(http_svc)); + + let tcp_svc = TimeoutLayer::new(Duration::from_secs(60)).into_layer( + TlsPeekRouter::new(tls_acceptor.into_layer(http_server.clone())).with_fallback(http_server), + ); + + let tcp_listener = TcpListener::bind(bind, exec) + .await + .map_err(OpaqueError::from_boxed) + .context("bind proxy meta http(s) server")?; + + let meta_addr = tcp_listener + .local_addr() + .context("get bound address for proxy meta http(s) server")?; + tracing::info!("meta http(s) server bound to: {meta_addr}"); + + Ok(MetaServer { + service: tcp_svc, + socket_address: meta_addr.into(), + listener: tcp_listener, + }) +} + +fn build_meta_http_router( + root_ca: RootCA, + proxy_addr: SocketAddress, + firewall: Firewall, + #[cfg(feature = "har")] har_client: HarClient, +) -> Router { #[cfg_attr(not(feature = "har"), allow(unused_mut))] - let mut http_router = Router::new() + let mut router = Router::new() .with_get("/", Html(META_SITE_INDEX_HTML)) .with_get("/ping", "pong") .with_get("/ca", move || { @@ -91,7 +135,7 @@ pub async fn build_meta_https_server( #[cfg(feature = "har")] { - http_router.set_post("/har/toggle", move || { + router.set_post("/har/toggle", move || { let har_client = har_client.clone(); async move { har_client @@ -103,34 +147,7 @@ pub async fn build_meta_https_server( }); } - let http_svc = ( - TraceLayer::new_for_http(), - AddRequiredResponseHeadersLayer::new() - .with_server_header_value(HeaderValue::from_static(crate::utils::env::project_name())), - ) - .into_layer(http_router); - - let http_server = HttpServer::auto(exec.clone()).service(Arc::new(http_svc)); - - let tcp_svc = TimeoutLayer::new(Duration::from_secs(60)).into_layer( - TlsPeekRouter::new(tls_acceptor.into_layer(http_server.clone())).with_fallback(http_server), - ); - - let tcp_listener = TcpListener::bind(bind, exec) - .await - .map_err(OpaqueError::from_boxed) - .context("bind proxy meta http(s) server")?; - - let meta_addr = tcp_listener - .local_addr() - .context("get bound address for proxy meta http(s) server")?; - tracing::info!("meta http(s) server bound to: {meta_addr}"); - - Ok(MetaServer { - service: tcp_svc, - socket_address: meta_addr.into(), - listener: tcp_listener, - }) + router } #[allow(clippy::too_many_arguments)] diff --git a/proxy/src/server/proxy/mod.rs b/proxy/src/server/proxy/mod.rs index 14b34d47..909cafd5 100644 --- a/proxy/src/server/proxy/mod.rs +++ b/proxy/src/server/proxy/mod.rs @@ -1,4 +1,4 @@ -use std::{path::Path, sync::Arc}; +use std::{convert::Infallible, path::Path, sync::Arc}; use rama::{ Layer, Service, @@ -111,13 +111,7 @@ pub async fn build_proxy_server( .local_addr() .context("fetch local addr of bound TCP port for proxy")?; - let https_client = self::client::new_https_client( - exec.clone(), - firewall.clone(), - upstream_proxy_addr.clone(), - )?; - - let http_proxy_mitm_server = self::server::new_mitm_server( + let socks5_proxy_mitm_server = self::server::new_mitm_server( guard.clone(), upstream_proxy_addr.clone(), mitm_all, @@ -126,15 +120,6 @@ pub async fn build_proxy_server( #[cfg(feature = "har")] har_export_layer.clone(), )?; - let socks5_proxy_mitm_server = self::server::new_mitm_server( - guard.clone(), - upstream_proxy_addr, - mitm_all, - tls_acceptor, - firewall, - #[cfg(feature = "har")] - har_export_layer.clone(), - )?; let socks5_proxy_router = Socks5PeekRouter::new( Socks5Acceptor::new(exec.clone()) @@ -145,7 +130,53 @@ pub async fn build_proxy_server( ))), ); - let http_inner_svc = ( + let http_mitm_proxy = create_http_mitm_proxy( + mitm_all, + guard, + upstream_proxy_addr, + tls_acceptor, + firewall, + #[cfg(feature = "har")] + har_export_layer, + )?; + + let http_server = HttpServer::auto(exec).service(Arc::new(http_mitm_proxy)); + + let tcp_inner_svc = socks5_proxy_router.with_fallback(http_server); + + tracing::info!(proxy.address = %proxy_addr, "local HTTP(S)/SOCKS5 proxy ready"); + + Ok(ProxyServer { + service: tcp_inner_svc, + socket_address: proxy_addr.into(), + listener: tcp_service, + }) +} + +fn create_http_mitm_proxy( + mitm_all: bool, + guard: ShutdownGuard, + upstream_proxy_addr: Option, + tls_acceptor: TlsAcceptorLayer, + firewall: Firewall, + #[cfg(feature = "har")] har_export_layer: HARExportLayer, +) -> Result, OpaqueError> { + let exec = Executor::graceful(guard.clone()); + + let http_proxy_mitm_server = self::server::new_mitm_server( + guard, + upstream_proxy_addr.clone(), + mitm_all, + tls_acceptor, + firewall.clone(), + #[cfg(feature = "har")] + har_export_layer.clone(), + )?; + + let https_client = self::client::new_https_client(exec.clone(), firewall, upstream_proxy_addr)?; + + Ok(( + MapResponseBodyLayer::new(Body::new), TraceLayer::new_for_http(), ConsumeErrLayer::trace(Level::DEBUG), #[cfg(feature = "har")] @@ -162,7 +193,7 @@ pub async fn build_proxy_server( // We make use use the void trailer parser to ensure we drop any ignored label. .with_labels::<((), self::auth::FirewallUserConfigParser)>(), UpgradeLayer::new( - exec.clone(), + exec, MethodMatcher::CONNECT, service_fn(http_connect_accept), Arc::new(http_proxy_mitm_server), @@ -173,19 +204,7 @@ pub async fn build_proxy_server( CompressionLayer::new(), // ============================================= ) - .into_layer(https_client); - - let http_service = HttpServer::auto(exec).service(Arc::new(http_inner_svc)); - - let tcp_inner_svc = socks5_proxy_router.with_fallback(http_service); - - tracing::info!(proxy.address = %proxy_addr, "local HTTP(S)/SOCKS5 proxy ready"); - - Ok(ProxyServer { - service: tcp_inner_svc, - socket_address: proxy_addr.into(), - listener: tcp_service, - }) + .into_layer(https_client)) } #[allow(clippy::too_many_arguments)] diff --git a/proxy_netbench/run.py b/proxy_netbench/run.py index c6349fee..2bedade5 100755 --- a/proxy_netbench/run.py +++ b/proxy_netbench/run.py @@ -73,8 +73,8 @@ def terminate_process(proc: Proc, timeout_s: float = 3.0) -> None: p.terminate() else: p.send_signal(signal.SIGTERM) - except Exception: - pass + except Exception as e: + eprint("failed to terminate process", e) try: p.wait(timeout=timeout_s) @@ -326,6 +326,11 @@ def compute_aggregate_from_events(events: List[Dict[str, Any]]) -> Dict[str, flo def write_kv_baseline(path: Path, metrics: Dict[str, float]) -> None: + """ + Path is given as cli arg 'save-baseline' by the same + user executing this script and is as such trusted as-is. + """ + path.parent.mkdir(parents=True, exist_ok=True) lines = [ f"avg_main_rps={metrics.get('avg_main_rps', 0.0)}", @@ -355,6 +360,11 @@ def parse_kv_summary(text: str) -> Dict[str, float]: def load_metrics_from_path(path: Path) -> Dict[str, float]: + """ + Path is given as cli arg 'compare' by the same + user executing this script and is as such trusted as-is. + """ + text = path.read_text(encoding="utf-8") if path.suffix.lower().endswith("jsonl"): events: List[Dict[str, Any]] = [] @@ -623,7 +633,8 @@ def cleanup(keep: bool) -> None: spin = 0 with run_jsonl.open("w", encoding="utf-8") as f_jsonl: - assert runner.stdout is not None + if not runner.stdout: + raise Exception("stdout is not defined for runner") last_spin_update = 0.0 while True: diff --git a/proxy_netbench/scripts/download_baselines.py b/proxy_netbench/scripts/download_baselines.py index ceae2dc9..295c647c 100644 --- a/proxy_netbench/scripts/download_baselines.py +++ b/proxy_netbench/scripts/download_baselines.py @@ -26,6 +26,8 @@ def api_get(url: str, token: str) -> dict: }, method="GET", ) + if not url.startswith(("http://", "https://")): + raise ValueError("URL must start with http:// or https://") with urllib.request.urlopen(req) as resp: return json.loads(resp.read().decode("utf-8")) @@ -40,6 +42,8 @@ def api_download(url: str, token: str, dest: Path) -> None: }, method="GET", ) + if not url.startswith(("http://", "https://")): + raise ValueError("URL must start with http:// or https://") with urllib.request.urlopen(req) as resp, dest.open("wb") as f: while True: chunk = resp.read(1024 * 128) diff --git a/proxy_netbench/src/cmd/run/client.rs b/proxy_netbench/src/cmd/run/client.rs index cfa18a06..92a2d374 100644 --- a/proxy_netbench/src/cmd/run/client.rs +++ b/proxy_netbench/src/cmd/run/client.rs @@ -24,7 +24,7 @@ use safechain_proxy_lib::client::{ WebClientConfig, new_web_client, transport::try_set_egress_address_overwrite, }; -pub fn http_cient( +pub fn http_client( exec: Executor, target: SocketAddress, concurrency: usize, diff --git a/proxy_netbench/src/cmd/run/mod.rs b/proxy_netbench/src/cmd/run/mod.rs index 5d50809b..ee3fecda 100644 --- a/proxy_netbench/src/cmd/run/mod.rs +++ b/proxy_netbench/src/cmd/run/mod.rs @@ -4,9 +4,10 @@ use rama::{ Service as _, error::{ErrorContext as _, OpaqueError}, graceful::ShutdownGuard, - http::{body::util::BodyExt, response::Parts}, + http::{Request, Response, body::util::BodyExt, response::Parts}, net::address::SocketAddress, rt::Executor, + service::BoxService, telemetry::tracing, }; @@ -97,85 +98,50 @@ pub async fn exec( guard: ShutdownGuard, args: RunCommand, ) -> Result<(), OpaqueError> { - let merged_cfg = merge_server_cfg(args.scenario, args.config); + let run_params = RunParameters::new(args.scenario, args.config, args.duration, args.warmup); - let target_rps = merged_cfg.target_rps.unwrap_or(200).max(1); - let burst_size = merged_cfg.burst_size.unwrap_or_default().max(1); - let jitter = merged_cfg.jitter.unwrap_or_default().clamp(0.0, 1.0); - - let request_count_per_iteration = (args.duration * target_rps as f64).next_up() as usize; - let request_count_per_warmup = (args.warmup * target_rps as f64).next_up() as usize; - - let concurrency = { - let c = merged_cfg.concurrency.unwrap_or_default(); - if c == 0 { - env::compute_concurrent_request_count() - } else { - c as usize - } - }; - - let client = self::client::http_cient( + let client = self::client::http_client( Executor::graceful(guard.clone()), args.target, - concurrency, + run_params.concurrency, args.proxy, ) .context("create HTTP(S) client")?; - tracing::info!( - %target_rps, - %burst_size, - %jitter, - %request_count_per_iteration, - %request_count_per_warmup, - %concurrency, - "client config parameters ready", - ); + tracing::info!(?run_params, "client config parameters ready",); let iterations = args.iterations.max(1); - let mut req_gen = match args.replay { - Some(har_fp) => RequestGenerator::new_replay_gen(RequestGeneratorReplayConfig { - har: har_fp, - iterations, - target_rps, - burst_size, - jitter, - emulate_timing: args.emulate_timing, - }) - .await - .context("create replay req generator")?, - None => RequestGenerator::new_mock_gen(RequestGeneratorMockConfig { - data, - iterations, - target_rps, - burst_size, - jitter, - request_count_per_iteration, - request_count_per_warmup, - products: args.products, - malware_ratio: args.malware_ratio, - }) - .await - .context("create mock req generator")?, - }; - - const REPORT_INTERVAL: Duration = Duration::from_secs(1); + let req_gen = new_request_generator( + data, + args.replay, + args.emulate_timing, + args.products, + args.malware_ratio, + iterations, + run_params, + ) + .await?; - let reporter: Box = if args.json { - const EMIT_EVENTS: bool = true; - Box::new(JsonlReporter::new(REPORT_INTERVAL, EMIT_EVENTS)) - } else { - Box::new(HumanReporter::new(REPORT_INTERVAL)) - }; + let reporter = new_reporter(args.json); - let (result_tx, result_rx) = mpsc::channel(concurrency * 8); + let (result_tx, result_rx) = mpsc::channel(run_params.concurrency * 8); guard.spawn_task_fn(|guard| report_worker(guard, reporter, result_rx)); + run_send_and_validate_loop(guard, run_params, req_gen, client, result_tx).await +} + +async fn run_send_and_validate_loop( + guard: ShutdownGuard, + run_params: RunParameters, + req_gen_input: RequestGenerator, + client: BoxService, + result_tx: mpsc::Sender, +) -> Result<(), OpaqueError> { + let mut req_gen = req_gen_input; let mut cancelled = std::pin::pin!(guard.clone_weak().into_cancelled()); - let concurrency = Arc::new(Semaphore::new(concurrency)); + let concurrency = Arc::new(Semaphore::new(run_params.concurrency)); loop { let GeneratedRequest { @@ -242,6 +208,60 @@ pub async fn exec( } } +async fn new_request_generator( + data: PathBuf, + replay: Option, + emulate_timing: bool, + products: Option, + malware_ratio: f64, + iterations: usize, + RunParameters { + target_rps, + burst_size, + jitter, + request_count_per_iteration, + request_count_per_warmup, + .. + }: RunParameters, +) -> Result { + Ok(match replay { + Some(har_fp) => RequestGenerator::new_replay_gen(RequestGeneratorReplayConfig { + har: har_fp, + iterations, + target_rps, + burst_size, + jitter, + emulate_timing, + }) + .await + .context("create replay req generator")?, + None => RequestGenerator::new_mock_gen(RequestGeneratorMockConfig { + data, + iterations, + target_rps, + burst_size, + jitter, + request_count_per_iteration, + request_count_per_warmup, + products, + malware_ratio, + }) + .await + .context("create mock req generator")?, + }) +} + +fn new_reporter(json: bool) -> Box { + const REPORT_INTERVAL: Duration = Duration::from_secs(1); + + if json { + const EMIT_EVENTS: bool = true; + Box::new(JsonlReporter::new(REPORT_INTERVAL, EMIT_EVENTS)) + } else { + Box::new(HumanReporter::new(REPORT_INTERVAL)) + } +} + struct ClientResult { result: Result, req_start: Instant, @@ -324,6 +344,54 @@ async fn report_worker( } } +#[derive(Debug, Clone, Copy)] +struct RunParameters { + target_rps: u32, + burst_size: u32, + jitter: f64, + request_count_per_iteration: usize, + request_count_per_warmup: usize, + concurrency: usize, +} + +impl RunParameters { + fn new( + scenario: Option, + config: Option, + iter_window_seconds: f64, + warmup_window_seconds: f64, + ) -> Self { + let merged_cfg = merge_server_cfg(scenario, config); + + let target_rps = merged_cfg.target_rps.unwrap_or(200).max(1); + let burst_size = merged_cfg.burst_size.unwrap_or_default().max(1); + let jitter = merged_cfg.jitter.unwrap_or_default().clamp(0.0, 1.0); + + let request_count_per_iteration = + (iter_window_seconds * target_rps as f64).next_up() as usize; + let request_count_per_warmup = + (warmup_window_seconds * target_rps as f64).next_up() as usize; + + let concurrency = { + let c = merged_cfg.concurrency.unwrap_or_default(); + if c == 0 { + env::compute_concurrent_request_count() + } else { + c as usize + } + }; + + Self { + target_rps, + burst_size, + jitter, + request_count_per_iteration, + request_count_per_warmup, + concurrency, + } + } +} + fn merge_server_cfg(scenario: Option, config: Option) -> ClientConfig { let scenario_cfg = scenario .map(|s| { diff --git a/proxy_netbench/src/cmd/run/requests/rps_pacer.rs b/proxy_netbench/src/cmd/run/requests/rps_pacer.rs index 846d7e63..0f75484b 100644 --- a/proxy_netbench/src/cmd/run/requests/rps_pacer.rs +++ b/proxy_netbench/src/cmd/run/requests/rps_pacer.rs @@ -37,11 +37,11 @@ impl RpsPacer { jitter: f64, rng: rand::rngs::SmallRng, ) -> Self { - let target_rps = target_rps.max(1) as f64; + let normalised_target_rps = target_rps.max(1) as f64; let capacity = burst_size.max(1) as f64; Self { - target_rps, + target_rps: normalised_target_rps, capacity, tokens: capacity, last: Instant::now(), From d6dac0ad0626a8ccd324b0f99392ea0cd7cf36c9 Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 31 Jan 2026 13:58:52 +0100 Subject: [PATCH 44/52] apply feedback aikibot part 2 --- proxy_netbench/src/cmd/run/reporter/human.rs | 15 +++++----- proxy_netbench/src/cmd/run/reporter/json.rs | 4 +-- .../src/cmd/run/requests/source/mock.rs | 30 +++++++++---------- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/proxy_netbench/src/cmd/run/reporter/human.rs b/proxy_netbench/src/cmd/run/reporter/human.rs index 2559d89c..4b881282 100644 --- a/proxy_netbench/src/cmd/run/reporter/human.rs +++ b/proxy_netbench/src/cmd/run/reporter/human.rs @@ -45,16 +45,17 @@ impl Reporter for HumanReporter { } self.last_tick = now; - let rps = self.interval_counts.total as f64 / self.interval.as_secs_f64(); - let (phase, it, idx) = self.last_pos.unwrap_or((Phase::Warmup, 0, 0)); + let interval_secs = self.interval.as_secs_f64(); + let rps = if interval_secs == 0. { + 0. + } else { + self.interval_counts.total as f64 / interval_secs + }; + let (phase, iteration, idx) = self.last_pos.unwrap_or((Phase::Warmup, 0, 0)); println!( - "t={:.1}s phase={:?} it={} idx={} rps={:.1} ok={} http_fail={} other_fail={} total_ok={} total_fail={}", + "t={:.1}s phase={phase:?} iteration={iteration} idx={idx} rps={rps:.1} ok={} http_fail={} other_fail={} total_ok={} total_fail={}", now.as_secs_f64(), - phase, - it, - idx, - rps, self.interval_counts.ok, self.interval_counts.http_fail, self.interval_counts.other_fail, diff --git a/proxy_netbench/src/cmd/run/reporter/json.rs b/proxy_netbench/src/cmd/run/reporter/json.rs index df7bff34..268b5a23 100644 --- a/proxy_netbench/src/cmd/run/reporter/json.rs +++ b/proxy_netbench/src/cmd/run/reporter/json.rs @@ -55,13 +55,13 @@ impl Reporter for JsonlReporter { self.last_tick = now; let rps = self.interval_counts.total as f64 / self.interval.as_secs_f64(); - let (phase, it, idx) = self.last_pos.unwrap_or((Phase::Warmup, 0, 0)); + let (phase, iteration, idx) = self.last_pos.unwrap_or((Phase::Warmup, 0, 0)); let line = serde_json::json!({ "type": "summary", "t_ms": now.as_millis(), "phase": match phase { Phase::Warmup => "warmup", Phase::Main => "main" }, - "iteration": it, + "iteration": iteration, "index": idx, "interval_ms": self.interval.as_millis(), "rps": rps, diff --git a/proxy_netbench/src/cmd/run/requests/source/mock.rs b/proxy_netbench/src/cmd/run/requests/source/mock.rs index 02c29fea..4063eef5 100644 --- a/proxy_netbench/src/cmd/run/requests/source/mock.rs +++ b/proxy_netbench/src/cmd/run/requests/source/mock.rs @@ -31,9 +31,9 @@ pub async fn rand_requests( .join(", ") ); - let mut rnd = RandomMocker::new(); - let mut vscode = VSCodeMocker::new(sync_storage.clone()); - let mut pypi = PyPIMocker::new(sync_storage); + let mut random_mocker = RandomMocker::new(); + let mut vscode_mocker = VSCodeMocker::new(sync_storage.clone()); + let mut pypi_mocker = PyPIMocker::new(sync_storage); let mut total_requests = Vec::with_capacity(iterations); for i in 1..=iterations { @@ -45,9 +45,9 @@ pub async fn rand_requests( request_count, &products, malware_ratio, - &mut rnd, - &mut vscode, - &mut pypi, + &mut random_mocker, + &mut vscode_mocker, + &mut pypi_mocker, ) .await?, ); @@ -58,9 +58,9 @@ pub async fn rand_requests( request_count_warmup, &products, malware_ratio, - &mut rnd, - &mut vscode, - &mut pypi, + &mut random_mocker, + &mut vscode_mocker, + &mut pypi_mocker, ) .await?; @@ -72,9 +72,9 @@ async fn rand_requests_inner( request_count: usize, products: &ProductValues, malware_ratio: f64, - rnd: &mut RandomMocker, - vscode: &mut VSCodeMocker, - pypi: &mut PyPIMocker, + random_mocker: &mut RandomMocker, + vscode_mocker: &mut VSCodeMocker, + pypi_mocker: &mut PyPIMocker, ) -> Result, OpaqueError> { let mut requests = VecDeque::with_capacity(request_count); @@ -86,9 +86,9 @@ async fn rand_requests_inner( let params = MockRequestParameters { malware_ratio }; let req = match product { - Product::None | Product::Unknown(_) => rnd.mock_request(params).await?, - Product::VSCode => vscode.mock_request(params).await?, - Product::PyPI => pypi.mock_request(params).await?, + Product::None | Product::Unknown(_) => random_mocker.mock_request(params).await?, + Product::VSCode => vscode_mocker.mock_request(params).await?, + Product::PyPI => pypi_mocker.mock_request(params).await?, }; requests.push_back(req) From ebf7506b923bcd295b44fe7477d83816023a2226 Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 31 Jan 2026 14:18:27 +0100 Subject: [PATCH 45/52] fix CI benchmark + address more feedback --- .github/workflows/proxy-benchmark.yml | 4 ++ proxy_netbench/src/cmd/run/mod.rs | 96 +++++++++++++++------------ 2 files changed, 59 insertions(+), 41 deletions(-) diff --git a/.github/workflows/proxy-benchmark.yml b/.github/workflows/proxy-benchmark.yml index e8c3a310..7d52d32d 100644 --- a/.github/workflows/proxy-benchmark.yml +++ b/.github/workflows/proxy-benchmark.yml @@ -36,6 +36,8 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 + with: + toolchain: stable - name: Setup Python uses: actions/setup-python@v5 @@ -119,6 +121,8 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 + with: + toolchain: stable - name: Setup Python uses: actions/setup-python@v5 diff --git a/proxy_netbench/src/cmd/run/mod.rs b/proxy_netbench/src/cmd/run/mod.rs index d573991e..cda6c4c7 100644 --- a/proxy_netbench/src/cmd/run/mod.rs +++ b/proxy_netbench/src/cmd/run/mod.rs @@ -313,54 +313,18 @@ async fn report_worker( let start = Instant::now(); loop { - let ClientResult { + let Some(ClientResult { result, req_start, phase, iteration, index, - } = tokio::select! { - _ = guard.cancelled() => { - tracing::debug!("exit report worker: guard shutdown"); - return; - } - - maybe_result = result_rx.recv() => { - let Some(result) = maybe_result else { - tracing::debug!("exit report worker: result senders closed"); - return; - }; - - result - } + }) = recv_next_client_result(&guard, &mut result_rx).await + else { + return; }; - let outcome = match result { - Ok(resp) => { - let status = resp.status.as_u16(); - if (200..400).contains(&status) { - RequestOutcome { - ok: true, - status: Some(status), - failure: None, - } - } else { - RequestOutcome { - ok: false, - status: Some(status), - failure: Some(FailureKind::HttpStatus), - } - } - } - Err(err) => { - tracing::debug!("non-http error: {err}"); - RequestOutcome { - ok: false, - status: None, - failure: Some(FailureKind::Other), - } - } - }; + let outcome = compute_outcome_for_client_result(result); let ev = RequestResultEvent { ts: std::time::SystemTime::now(), @@ -379,6 +343,56 @@ async fn report_worker( } } +async fn recv_next_client_result( + guard: &ShutdownGuard, + result_rx: &mut mpsc::Receiver, +) -> Option { + tokio::select! { + _ = guard.cancelled() => { + tracing::debug!("exit report worker: guard shutdown"); + None + } + + maybe_result = result_rx.recv() => { + let Some(result) = maybe_result else { + tracing::debug!("exit report worker: result senders closed"); + return None; + }; + + Some(result) + } + } +} + +fn compute_outcome_for_client_result(result: Result) -> RequestOutcome { + match result { + Ok(resp) => { + let status = resp.status.as_u16(); + if (200..400).contains(&status) { + RequestOutcome { + ok: true, + status: Some(status), + failure: None, + } + } else { + RequestOutcome { + ok: false, + status: Some(status), + failure: Some(FailureKind::HttpStatus), + } + } + } + Err(err) => { + tracing::debug!("non-http error: {err}"); + RequestOutcome { + ok: false, + status: None, + failure: Some(FailureKind::Other), + } + } + } +} + #[derive(Debug, Clone, Copy)] struct RunParameters { target_rps: u32, From 792e5a64fb77c8aa5f489fc03512299a1f0730bc Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 31 Jan 2026 16:15:19 +0100 Subject: [PATCH 46/52] prepare netbench emulate to also handle har replays --- proxy_netbench/src/cmd/emulate/client.rs | 46 ++++--- proxy_netbench/src/cmd/emulate/mod.rs | 161 ++++++++++++----------- proxy_netbench/src/cmd/emulate/source.rs | 129 ++++++++++++++++++ proxy_netbench/src/mock/mod.rs | 57 +++++++- proxy_netbench/src/mock/pypi.rs | 4 +- proxy_netbench/src/mock/random.rs | 4 +- proxy_netbench/src/mock/vscode.rs | 4 +- 7 files changed, 298 insertions(+), 107 deletions(-) create mode 100644 proxy_netbench/src/cmd/emulate/source.rs diff --git a/proxy_netbench/src/cmd/emulate/client.rs b/proxy_netbench/src/cmd/emulate/client.rs index a55e565e..3ae1954e 100644 --- a/proxy_netbench/src/cmd/emulate/client.rs +++ b/proxy_netbench/src/cmd/emulate/client.rs @@ -1,8 +1,8 @@ -use std::{path::PathBuf, sync::Arc, time::Duration}; +use std::{sync::Arc, time::Duration}; use rama::{ Layer as _, Service, - error::{ErrorContext as _, OpaqueError}, + error::OpaqueError, graceful::ShutdownGuard, http::{ Request, Response, StatusCode, Uri, @@ -10,7 +10,7 @@ use rama::{ BidirectionalWriter, RequestWriterLayer, ResponseWriterLayer, WriterMode, }, service::web::{ - IntoEndpointService, Router, + Router, extract::{Json, State}, response::IntoResponse, }, @@ -23,8 +23,11 @@ use rama::{ use safechain_proxy_lib::{ firewall::{Firewall, events::BlockedEvent, notifier::EventNotifier}, - storage, + storage::SyncCompactDataStorage, }; +use tokio::sync::Mutex; + +use crate::cmd::emulate::Source; #[derive(Debug, Clone)] pub(super) struct Client { @@ -32,17 +35,13 @@ pub(super) struct Client { web_svc: BoxService, } -pub(super) async fn new_client(guard: ShutdownGuard, data: PathBuf) -> Result { +pub(super) async fn new_client( + guard: ShutdownGuard, + data_storage: SyncCompactDataStorage, + source: Source, +) -> Result { let shared_data = Data::default(); - let data_storage = - storage::SyncCompactDataStorage::try_new(data.clone()).with_context(|| { - format!( - "create compact data storage using dir at path '{}'", - data.display() - ) - })?; - let exec = Executor::graceful(guard.clone()); let traffic_writer = @@ -70,9 +69,9 @@ pub(super) async fn new_client(guard: ShutdownGuard, data: PathBuf) -> Result>, +} + +impl Service for MockHttpClient { + type Output = Response; + type Error = OpaqueError; + + #[inline(always)] + async fn serve(&self, req: Request) -> Result { + self.source.lock().await.next_response_for(req).await + } +} + #[derive(Debug, Clone, Default)] struct Data { blocked_events: Arc>, diff --git a/proxy_netbench/src/cmd/emulate/mod.rs b/proxy_netbench/src/cmd/emulate/mod.rs index 802d2cc1..143eb648 100644 --- a/proxy_netbench/src/cmd/emulate/mod.rs +++ b/proxy_netbench/src/cmd/emulate/mod.rs @@ -1,33 +1,33 @@ -use std::{path::PathBuf, str::FromStr}; +use std::path::PathBuf; use rama::{ Service, error::{ErrorContext as _, OpaqueError}, graceful::ShutdownGuard, - http::{Request, body::util::BodyExt, convert::curl}, + http::{body::util::BodyExt, convert::curl}, telemetry::tracing, }; use clap::Args; -use safechain_proxy_lib::{firewall::version::PackageVersion, storage}; - -use crate::mock::{self, MockRequestParameters, RequestMocker}; +use safechain_proxy_lib::{ + firewall::version::PackageVersion, + storage::{self, SyncCompactDataStorage}, +}; mod client; +mod source; -#[derive(Debug, Clone)] -/// Product to emulate -enum Product { - VSCode, - PyPI, -} +use self::{ + client::Client, + source::{Source, SourceKind}, +}; #[derive(Debug, Clone, Args)] /// emulate a request that is to be blocked pub struct EmulateCommand { - /// product to emulate (e.g. vscode, pypi, ...) + /// emulation source (synthetic id such as vscode, pypi, or else a HAR file path to replay) #[arg(required = true)] - product: Product, + source: SourceKind, /// instead of emulating return a curl request #[arg(long, default_value_t = false)] @@ -39,45 +39,28 @@ pub async fn exec( guard: ShutdownGuard, args: EmulateCommand, ) -> Result<(), OpaqueError> { - let req = tokio::select! { - _ = guard.cancelled() => { - return Err(OpaqueError::from_display("exit cmd while generating mock req")); - } - - result = create_mock_req(data.clone(), args.product) => { - result? - } - }; - - if args.curl { - let (parts, body) = req.into_parts(); - let bytes = body - .collect() - .await - .context("collect (mock) req payload")? - .to_bytes(); - - println!( - "{}", - curl::cmd_string_for_request_parts_and_payload(&parts, &bytes) - ); - return Ok(()); - } - - tracing::info!("emulate request: {req:?}"); - emulate_req(data, guard, req).await -} - -async fn emulate_req(data: PathBuf, guard: ShutdownGuard, req: Request) -> Result<(), OpaqueError> { - let client = self::client::new_client(guard, data).await?; - - let resp = client.serve(req).await?; - - if resp.status().is_success() { - return Err(OpaqueError::from_display(format!( - "unexpected response: {resp:?}" - ))); - } + let data_storage = run_future_unless_cancelled( + &guard, + "create data storage", + create_data_storage(data.clone()), + ) + .await?; + + let source = run_future_unless_cancelled( + &guard, + "create source", + Source::try_new(args.source, data_storage.clone()), + ) + .await?; + + let client = run_future_unless_cancelled( + &guard, + "create mock client", + self::client::new_client(guard.clone(), data_storage, source.clone()), + ) + .await?; + + exec_emulate_loop(&guard, client.clone(), source, args.curl).await?; client.wait_for_blocked_events().await?; for blocked_event in client.blocked_events() { @@ -97,7 +80,40 @@ async fn emulate_req(data: PathBuf, guard: ShutdownGuard, req: Request) -> Resul Ok(()) } -async fn create_mock_req(data: PathBuf, product: Product) -> Result { +async fn exec_emulate_loop( + guard: &ShutdownGuard, + client: Client, + mut source: Source, + curl: bool, +) -> Result<(), OpaqueError> { + loop { + let Some(req) = + run_future_unless_cancelled(guard, "get next request from src", source.next_request()) + .await? + else { + return Ok(()); + }; + + if curl { + let (parts, body) = req.into_parts(); + let bytes = body + .collect() + .await + .context("collect (mock) req payload")? + .to_bytes(); + + println!( + "{}", + curl::cmd_string_for_request_parts_and_payload(&parts, &bytes) + ); + continue; + } + + let _resp = client.serve(req).await?; + } +} + +async fn create_data_storage(data: PathBuf) -> Result { tokio::fs::create_dir_all(&data) .await .with_context(|| format!("create data directory at path '{}'", data.display()))?; @@ -110,31 +126,24 @@ async fn create_mock_req(data: PathBuf, product: Product) -> Result mock::vscode::VSCodeMocker::new(data_storage) - .mock_request(mock_req_params) - .await - .context("mock vscode request"), - Product::PyPI => mock::pypi::PyPIMocker::new(data_storage) - .mock_request(mock_req_params) - .await - .context("mock pypi request"), - } + Ok(data_storage) } -impl FromStr for Product { - type Err = OpaqueError; - - fn from_str(s: &str) -> Result { - let trimmed_s = s.trim(); - if trimmed_s.eq_ignore_ascii_case("vscode") { - Ok(Self::VSCode) - } else if trimmed_s.eq_ignore_ascii_case("pypi") { - Ok(Self::PyPI) - } else { - Err(OpaqueError::from_display(format!("unknown variant '{s}'"))) +async fn run_future_unless_cancelled( + guard: &ShutdownGuard, + desc: &'static str, + fut: F, +) -> Result +where + F: Future>, +{ + tokio::select! { + _ = guard.cancelled() => { + Err(OpaqueError::from_display(format!("exit cmd while: {desc}"))) + } + + result = fut => { + result } } } diff --git a/proxy_netbench/src/cmd/emulate/source.rs b/proxy_netbench/src/cmd/emulate/source.rs new file mode 100644 index 00000000..dd475044 --- /dev/null +++ b/proxy_netbench/src/cmd/emulate/source.rs @@ -0,0 +1,129 @@ +use std::{path::PathBuf, str::FromStr, sync::Arc}; + +use rama::{ + error::{ErrorContext as _, OpaqueError}, + http::{ + Request, Response, StatusCode, headers::HeaderMapExt, + service::web::response::IntoResponse as _, + }, +}; +use safechain_proxy_lib::storage::SyncCompactDataStorage; + +use crate::{ + http::{ + MockReplayIndex, + har::{self, HarEntry}, + }, + mock::{self, BoxRequestMocker, RequestMocker}, +}; + +#[derive(Debug, Clone)] +/// Emulation source, used to generate requests/responses +/// used in emulation. +pub(super) enum Source { + /// Generate malware requests synthetically + Synthetic(BoxRequestMocker), + /// Replay requests from the given intries + /// + /// (ignores original timings) + Har { + index: usize, + entries: Arc<[HarEntry]>, + }, +} + +impl Source { + pub(super) async fn try_new( + kind: SourceKind, + data_storage: SyncCompactDataStorage, + ) -> Result { + match kind { + SourceKind::VSCode => Ok(Self::Synthetic( + mock::vscode::VSCodeMocker::new(data_storage).into_dyn(), + )), + SourceKind::PyPI => Ok(Self::Synthetic( + mock::pypi::PyPIMocker::new(data_storage).into_dyn(), + )), + SourceKind::Har(path) => { + let entries = har::load_har_entries(path).await?.into(); + Ok(Self::Har { index: 0, entries }) + } + } + } + + pub(super) async fn next_request(&mut self) -> Result, OpaqueError> { + match self { + Source::Synthetic(mocker) => mocker + .mock_request(mock::MockRequestParameters { malware_ratio: 1. }) + .await + .map(Some), + Source::Har { index, entries } => Ok(entries.get(*index).map(|entry| { + let mut req = entry.request.clone_as_http_request(); + req.headers_mut().typed_insert(MockReplayIndex(*index)); + *index += 1; + req + })), + } + } + + pub(super) async fn next_response_for( + &mut self, + req: Request, + ) -> Result { + match self { + Source::Synthetic(_) => Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + "synthetic request was not blocked", + ) + .into_response()), + Source::Har { index: _, entries } => { + let Some(MockReplayIndex(index)) = req.headers().typed_get() else { + return Err(OpaqueError::from_display( + "har response could not replay: mock replay index missing", + )); + }; + + entries + .get(index) + .and_then(|entry| entry.response.as_ref()) + .map(|resp| resp.clone_as_http_response()) + .with_context(|| format!("har response for index: {index})")) + } + } + } +} + +#[derive(Debug, Clone)] +/// Kind of source to use for emulation +pub(super) enum SourceKind { + // Synthetic data based on vscode + VSCode, + // Synthetic data based on pypi + PyPI, + // Replay requests + responses from HAR + Har(PathBuf), +} + +impl FromStr for SourceKind { + type Err = OpaqueError; + + fn from_str(s: &str) -> Result { + let trimmed_s = s.trim(); + if trimmed_s.eq_ignore_ascii_case("vscode") { + Ok(Self::VSCode) + } else if trimmed_s.eq_ignore_ascii_case("pypi") { + Ok(Self::PyPI) + } else { + let path: PathBuf = trimmed_s + .parse() + .context("parse unknown kind as (har) fs path")?; + if !path.exists() { + return Err(OpaqueError::from_display(format!( + "(source kind) HAR file path does not exist: '{}'", + path.display() + ))); + } + Ok(Self::Har(path)) + } + } +} diff --git a/proxy_netbench/src/mock/mod.rs b/proxy_netbench/src/mock/mod.rs index fe0b0974..a9b4aed2 100644 --- a/proxy_netbench/src/mock/mod.rs +++ b/proxy_netbench/src/mock/mod.rs @@ -1,6 +1,9 @@ // Generate mock data such as fake requests for a product. -use rama::http::Request; +use std::{fmt, pin::Pin, sync::Arc}; + +use rama::{error::OpaqueError, http::Request}; +use tokio::sync::Mutex; pub mod pypi; pub mod random; @@ -17,12 +20,54 @@ impl Default for MockRequestParameters { } } -pub trait RequestMocker: Send + Sync + 'static { - /// The type of error returned by the request mocker. - type Error: Send + 'static; - +pub trait RequestMocker: fmt::Debug + Sized + Send + Sync + 'static { fn mock_request( &mut self, params: MockRequestParameters, - ) -> impl Future> + Send + '_; + ) -> impl Future> + Send + '_; + + /// Converts this [`RequestMocker`] into a [`DynRequestMocker`] trait object. + fn into_dyn(self) -> BoxRequestMocker { + BoxRequestMocker(Arc::new(Mutex::new(self))) + } +} + +#[derive(Debug, Clone)] +pub struct BoxRequestMocker(Arc>); + +impl RequestMocker for BoxRequestMocker { + #[inline(always)] + async fn mock_request( + &mut self, + params: MockRequestParameters, + ) -> Result { + self.0.lock().await.dyn_mock_request(params).await + } + + fn into_dyn(self) -> BoxRequestMocker { + self.clone() + } +} + +/// Internal trait for dynamic dispatch of Async Traits, +/// implemented according to the pioneers of this Design Pattern +/// found at +/// and widely published at . +#[allow(clippy::type_complexity)] +pub trait DynRequestMocker: fmt::Debug { + fn dyn_mock_request( + &mut self, + params: MockRequestParameters, + ) -> Pin> + Send + '_>>; +} + +impl DynRequestMocker for M { + #[inline(always)] + /// see [`RequestMocker::mock_request`] for more information. + fn dyn_mock_request( + &mut self, + params: MockRequestParameters, + ) -> Pin> + Send + '_>> { + Box::pin(self.mock_request(params)) + } } diff --git a/proxy_netbench/src/mock/pypi.rs b/proxy_netbench/src/mock/pypi.rs index e5560a86..142f908c 100644 --- a/proxy_netbench/src/mock/pypi.rs +++ b/proxy_netbench/src/mock/pypi.rs @@ -88,12 +88,10 @@ impl PyPIMocker { } impl RequestMocker for PyPIMocker { - type Error = OpaqueError; - async fn mock_request( &mut self, params: super::MockRequestParameters, - ) -> Result { + ) -> Result { let uri = self.random_uri(params.malware_ratio).await?; let mut req = Request::new(Body::empty()); diff --git a/proxy_netbench/src/mock/random.rs b/proxy_netbench/src/mock/random.rs index 87a8c5f9..e9a30ac3 100644 --- a/proxy_netbench/src/mock/random.rs +++ b/proxy_netbench/src/mock/random.rs @@ -20,12 +20,10 @@ impl RandomMocker { } impl RequestMocker for RandomMocker { - type Error = OpaqueError; - async fn mock_request( &mut self, _params: MockRequestParameters, - ) -> Result { + ) -> Result { let uri = random_uri()?; let mut req = Request::new(Body::empty()); diff --git a/proxy_netbench/src/mock/vscode.rs b/proxy_netbench/src/mock/vscode.rs index f94ae3b3..ff86b19d 100644 --- a/proxy_netbench/src/mock/vscode.rs +++ b/proxy_netbench/src/mock/vscode.rs @@ -84,12 +84,10 @@ impl VSCodeMocker { } impl RequestMocker for VSCodeMocker { - type Error = OpaqueError; - async fn mock_request( &mut self, params: super::MockRequestParameters, - ) -> Result { + ) -> Result { let uri = self.random_uri(params.malware_ratio).await?; let mut req = Request::new(Body::empty()); From 44c47444b8554c8f7af55aa38bb34c38edbccd07 Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 31 Jan 2026 16:16:54 +0100 Subject: [PATCH 47/52] add remaining todo --- proxy_netbench/src/cmd/emulate/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/proxy_netbench/src/cmd/emulate/mod.rs b/proxy_netbench/src/cmd/emulate/mod.rs index 143eb648..c63e72a6 100644 --- a/proxy_netbench/src/cmd/emulate/mod.rs +++ b/proxy_netbench/src/cmd/emulate/mod.rs @@ -32,6 +32,12 @@ pub struct EmulateCommand { /// instead of emulating return a curl request #[arg(long, default_value_t = false)] curl: bool, + // TODO: + // - support filters: range, domain, path, ... + // - support export success requests to a file under dir (to create test cases from this) + // - add under firewall tests using such requests to ensure they do block :) + // - write diagnostics docs + // - apply last feedback aikibot } pub async fn exec( From a4a3b6f361f9c06cf6834e7677df23333cb01cb0 Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 31 Jan 2026 18:17:53 +0100 Subject: [PATCH 48/52] support filters in emulate cmd --- Cargo.lock | 6 +- Cargo.toml | 4 +- proxy/src/firewall/domain_matcher.rs | 6 +- proxy/src/firewall/mod.rs | 10 +- proxy/src/http/headers.rs | 23 +- proxy/src/http/mod.rs | 2 +- proxy/src/utils/env.rs | 7 +- proxy_netbench/src/cmd/emulate/client.rs | 7 +- .../src/cmd/emulate/filters/domain.rs | 116 ++++++++++ proxy_netbench/src/cmd/emulate/filters/mod.rs | 55 +++++ .../src/cmd/emulate/filters/path.rs | 138 ++++++++++++ .../src/cmd/emulate/filters/range.rs | 213 ++++++++++++++++++ proxy_netbench/src/cmd/emulate/mod.rs | 95 +++++++- proxy_netbench/src/config/product.rs | 20 +- 14 files changed, 663 insertions(+), 39 deletions(-) create mode 100644 proxy_netbench/src/cmd/emulate/filters/domain.rs create mode 100644 proxy_netbench/src/cmd/emulate/filters/mod.rs create mode 100644 proxy_netbench/src/cmd/emulate/filters/path.rs create mode 100644 proxy_netbench/src/cmd/emulate/filters/range.rs diff --git a/Cargo.lock b/Cargo.lock index e0bc9629..31d8b058 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1633,7 +1633,7 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "netbench" -version = "0.2.1" +version = "1.0.0" dependencies = [ "clap", "mimalloc", @@ -2892,7 +2892,7 @@ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "safechain-proxy" -version = "0.2.1" +version = "1.0.0" dependencies = [ "clap", "mimalloc", @@ -2913,7 +2913,7 @@ dependencies = [ [[package]] name = "safechain-proxy-lib" -version = "0.2.1" +version = "1.0.0" dependencies = [ "apple-native-keyring-store", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index db22e98d..316db032 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["proxy", "proxy_cli", "proxy_fuzz", "proxy_netbench"] resolver = "3" [workspace.package] -version = "0.2.1" +version = "1.0.0" edition = "2024" rust-version = "1.93" @@ -25,7 +25,7 @@ postcard = "1.1" radix_trie = "0.3" rand = "0.9" rustls-platform-verifier = "0.6" -safechain-proxy-lib = { path = "./proxy", version = "0.2.1" } +safechain-proxy-lib = { path = "./proxy", version = "1.0.0" } secrecy = "0.10" serde = "1.0" serde_html_form = "0.4" diff --git a/proxy/src/firewall/domain_matcher.rs b/proxy/src/firewall/domain_matcher.rs index 94a100db..0a3e6200 100644 --- a/proxy/src/firewall/domain_matcher.rs +++ b/proxy/src/firewall/domain_matcher.rs @@ -1,10 +1,10 @@ use rama::net::address::{AsDomainRef, Domain, DomainParentMatch, DomainTrie}; #[derive(Debug)] -pub(super) struct DomainMatcher(DomainTrie); +pub struct DomainMatcher(DomainTrie); impl DomainMatcher { - pub(super) fn is_match(&self, domain: &Domain) -> bool { + pub fn is_match(&self, domain: &Domain) -> bool { match self.0.match_parent(domain) { None => false, Some(DomainParentMatch { @@ -19,7 +19,7 @@ impl DomainMatcher { } } - pub(super) fn iter(&self) -> impl Iterator { + pub fn iter(&self) -> impl Iterator { self.0.iter().map(|t| t.0) } } diff --git a/proxy/src/firewall/mod.rs b/proxy/src/firewall/mod.rs index 59931bb2..80320ed0 100644 --- a/proxy/src/firewall/mod.rs +++ b/proxy/src/firewall/mod.rs @@ -7,6 +7,7 @@ use rama::{ http::{ Body, HeaderValue, Request, Response, header::CONTENT_TYPE, + headers::HeaderMapExt, layer::{ decompression::DecompressionLayer, map_request_body::MapRequestBodyLayer, @@ -33,9 +34,11 @@ pub mod version; mod domain_matcher; mod pac; -use self::domain_matcher::DomainMatcher; +pub use self::domain_matcher::DomainMatcher; -use crate::{firewall::notifier::EventNotifier, storage::SyncCompactDataStorage}; +use crate::{ + firewall::notifier::EventNotifier, http::BlockedByHeader, storage::SyncCompactDataStorage, +}; use self::rule::{RequestAction, Rule}; @@ -173,8 +176,9 @@ impl Firewall { tracing::trace!("firewall rule for {} allows request", rule.product_name()); mod_req = new_mod_req } - RequestAction::Block(blocked) => { + RequestAction::Block(mut blocked) => { self.record_blocked_event(blocked.info.clone()).await; + blocked.response.headers_mut().typed_insert(BlockedByHeader); return Ok(RequestAction::Block(blocked)); } } diff --git a/proxy/src/http/headers.rs b/proxy/src/http/headers.rs index 45786b29..cc911c36 100644 --- a/proxy/src/http/headers.rs +++ b/proxy/src/http/headers.rs @@ -1,6 +1,7 @@ use rama::http::{ - HeaderMap, + HeaderMap, HeaderName, HeaderValue, header::{CACHE_CONTROL, ETAG, Entry, LAST_MODIFIED}, + headers::{HeaderEncode, TypedHeader}, }; use rama::telemetry::tracing; @@ -17,6 +18,26 @@ pub fn remove_cache_headers(headers: &mut HeaderMap) { } } +#[derive(Debug, Clone, Copy)] +/// x-blocked-by http header that is used by the firewall +/// in case a request was blocked. +pub struct BlockedByHeader; + +impl TypedHeader for BlockedByHeader { + fn name() -> &'static HeaderName { + static NAME: HeaderName = HeaderName::from_static("x-blocked-by"); + &NAME + } +} + +impl HeaderEncode for BlockedByHeader { + fn encode>(&self, values: &mut E) { + values.extend(std::iter::once(HeaderValue::from_static( + crate::utils::env::server_identifier(), + ))) + } +} + #[cfg(test)] mod tests { use super::remove_cache_headers; diff --git a/proxy/src/http/mod.rs b/proxy/src/http/mod.rs index 3f91b001..69da924c 100644 --- a/proxy/src/http/mod.rs +++ b/proxy/src/http/mod.rs @@ -4,7 +4,7 @@ mod content_type; pub use content_type::KnownContentType; mod headers; -pub use headers::remove_cache_headers; +pub use headers::{BlockedByHeader, remove_cache_headers}; mod req_info; pub use req_info::try_get_domain_for_req; diff --git a/proxy/src/utils/env.rs b/proxy/src/utils/env.rs index 76490401..0b62299e 100644 --- a/proxy/src/utils/env.rs +++ b/proxy/src/utils/env.rs @@ -3,7 +3,12 @@ pub const fn project_name() -> &'static str { } pub const fn server_identifier() -> &'static str { - concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")) + concat!( + "Aikido ", + env!("CARGO_PKG_NAME"), + "/", + env!("CARGO_PKG_VERSION") + ) } pub fn compute_concurrent_request_count() -> usize { diff --git a/proxy_netbench/src/cmd/emulate/client.rs b/proxy_netbench/src/cmd/emulate/client.rs index 3ae1954e..089f21fb 100644 --- a/proxy_netbench/src/cmd/emulate/client.rs +++ b/proxy_netbench/src/cmd/emulate/client.rs @@ -44,8 +44,11 @@ pub(super) async fn new_client( let exec = Executor::graceful(guard.clone()); - let traffic_writer = - BidirectionalWriter::stdout_unbounded(&exec, Some(WriterMode::All), Some(WriterMode::All)); + let traffic_writer = BidirectionalWriter::stdout_unbounded( + &exec, + Some(WriterMode::Headers), + Some(WriterMode::Headers), + ); let notifier_web_client = Arc::new( MapErrLayer::new(OpaqueError::from_std).into_layer( diff --git a/proxy_netbench/src/cmd/emulate/filters/domain.rs b/proxy_netbench/src/cmd/emulate/filters/domain.rs new file mode 100644 index 00000000..89f43064 --- /dev/null +++ b/proxy_netbench/src/cmd/emulate/filters/domain.rs @@ -0,0 +1,116 @@ +use std::{str::FromStr, sync::Arc}; + +use rama::{ + error::BoxError, + http::Request, + net::{address::Domain, http::RequestContext}, +}; +use safechain_proxy_lib::firewall::DomainMatcher; + +#[derive(Debug, Default, Clone)] +pub struct DomainFilter(Option>); + +/// clap arg parser +pub fn parse_domain_filter(input: &str) -> Result { + let domains_result: Result, _> = input + .split(",") + .filter(|s| !s.is_empty()) + .map(Domain::from_str) + .collect(); + let matcher = DomainMatcher::from_iter(domains_result?); + + if matcher.iter().next().is_none() { + Ok(DomainFilter(None)) + } else { + Ok(DomainFilter(Some(Arc::new(matcher)))) + } +} + +impl DomainFilter { + pub fn match_req(&self, req: &Request) -> bool { + let Some(matcher) = self.0.as_ref() else { + // no matcher matches all + return true; + }; + + RequestContext::try_from(req) + .ok() + .map(|ctx| ctx.host_with_port()) + .and_then(|v| v.host.into_domain()) + .map(|domain| matcher.is_match(&domain)) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use rama::http::{self, Body}; + + use super::*; + + fn req_with_absolute_uri(uri: &str) -> Request { + Request::builder().uri(uri).body(Body::empty()).unwrap() + } + + fn req_with_relative_uri_and_host(host: &str) -> Request { + Request::builder() + .uri("/some/path") + .header(http::header::HOST, host) + .body(Body::empty()) + .unwrap() + } + + #[test] + fn match_req_no_matcher_matches_all_even_if_request_has_no_host() { + let filter = parse_domain_filter("").unwrap(); + assert!(filter.match_req(&req_with_absolute_uri("http://example.com/"))); + + // Relative URI without a Host header is typically not enough context. + // With no matcher, we should still match all. + let req = Request::builder().uri("/").body(Body::empty()).unwrap(); + assert!(filter.match_req(&req)); + } + + #[test] + fn match_req_matches_domain_from_absolute_uri_and_ignores_port() { + let filter = parse_domain_filter("example.com").unwrap(); + + assert!(filter.match_req(&req_with_absolute_uri("http://example.com/"))); + assert!(filter.match_req(&req_with_absolute_uri("http://example.com:8080/"))); + assert!(!filter.match_req(&req_with_absolute_uri("http://nope.com/"))); + } + + #[test] + fn match_req_matches_domain_from_host_header_when_uri_is_relative() { + let filter = parse_domain_filter("example.com").unwrap(); + + assert!(filter.match_req(&req_with_relative_uri_and_host("example.com"))); + assert!(filter.match_req(&req_with_relative_uri_and_host("example.com:443"))); + assert!(!filter.match_req(&req_with_relative_uri_and_host("nope.com"))); + } + + #[test] + fn match_req_returns_false_when_context_or_domain_cannot_be_derived() { + let filter = parse_domain_filter("example.com").unwrap(); + + // No absolute URI host and no Host header means host extraction should fail, + // and match_req should fall back to false when a matcher exists. + let req = Request::builder().uri("/").body(Body::empty()).unwrap(); + assert!(!filter.match_req(&req)); + + // An IP address is not a domain, so into_domain() should yield None, + // leading to false as well. + assert!(!filter.match_req(&req_with_absolute_uri("http://127.0.0.1/"))); + assert!(!filter.match_req(&req_with_relative_uri_and_host("127.0.0.1:8080"))); + } + + #[test] + fn match_req_supports_multiple_domains_from_csv() { + let filter = parse_domain_filter("example.com,foo.test,bar.org").unwrap(); + + assert!(filter.match_req(&req_with_absolute_uri("http://example.com/"))); + assert!(filter.match_req(&req_with_absolute_uri("http://foo.test/"))); + assert!(filter.match_req(&req_with_absolute_uri("http://bar.org/"))); + assert!(!filter.match_req(&req_with_absolute_uri("http://nope.com/"))); + } +} diff --git a/proxy_netbench/src/cmd/emulate/filters/mod.rs b/proxy_netbench/src/cmd/emulate/filters/mod.rs new file mode 100644 index 00000000..579c417b --- /dev/null +++ b/proxy_netbench/src/cmd/emulate/filters/mod.rs @@ -0,0 +1,55 @@ +use rama::http::Request; + +pub mod domain; +pub mod path; +pub mod range; + +#[derive(Debug)] +pub struct SourceFilter { + range: self::range::RangeFilter, + domain: Option, + path: Option, +} + +impl SourceFilter { + pub fn new_synthetic_filter( + range: Option, + domain: Option, + path: Option, + ) -> Self { + Self { + range: range.unwrap_or_else(self::range::RangeFilter::new_single), + domain, + path, + } + } + + pub fn new_har_filter( + range: Option, + domain: Option, + path: Option, + ) -> Self { + Self { + range: range.unwrap_or_else(self::range::RangeFilter::new_infinite), + domain, + path, + } + } + + pub fn filter(&mut self, req: &Request) -> bool { + if let Some(domain_matcher) = self.domain.as_ref() + && !domain_matcher.match_req(req) + { + return false; + } + + if let Some(path_matcher) = self.path.as_ref() + && !path_matcher.match_req(req) + { + return false; + } + + // IMPORTANT: range is post-filtered! + self.range.advance() + } +} diff --git a/proxy_netbench/src/cmd/emulate/filters/path.rs b/proxy_netbench/src/cmd/emulate/filters/path.rs new file mode 100644 index 00000000..a1cea3ba --- /dev/null +++ b/proxy_netbench/src/cmd/emulate/filters/path.rs @@ -0,0 +1,138 @@ +use std::{convert::Infallible, sync::Arc}; + +use rama::http::{Request, matcher::PathMatcher}; + +#[derive(Debug, Clone)] +pub struct PathFilter(Arc<[PathMatcher]>); + +/// clap arg parser +pub fn parse_path_filter(input: &str) -> Result { + let path_matcher_result = input + .split(",") + .filter(|s| !s.is_empty()) + .map(PathMatcher::new) + .collect(); + Ok(PathFilter(path_matcher_result)) +} + +impl PathFilter { + pub(super) fn match_req(&self, req: &Request) -> bool { + if self.0.is_empty() { + // no matcher matches all + return true; + } + + let path = req.uri().path(); + for path_matcher in self.0.iter() { + if path_matcher.matches_path(None, path) { + return true; + } + } + + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rama::http::{Body, Request}; + + fn req(path: &str) -> Request { + Request::builder().uri(path).body(Body::empty()).unwrap() + } + + #[test] + fn parse_path_filter_empty_means_match_all() { + let filter = parse_path_filter("").unwrap(); + assert!(filter.match_req(&req("/"))); + assert!(filter.match_req(&req("/foo"))); + assert!(filter.match_req(&req("/foo/bar"))); + } + + #[test] + fn parse_path_filter_ignores_empty_segments_from_csv() { + let filter = parse_path_filter(",,").unwrap(); + assert!(filter.match_req(&req("/"))); + assert!(filter.match_req(&req("/anything"))); + } + + #[test] + fn match_req_exact_paths() { + let filter = parse_path_filter("/,/foo,/foo/bar").unwrap(); + + assert!(filter.match_req(&req("/"))); + assert!(filter.match_req(&req("/foo"))); + assert!(filter.match_req(&req("/foo/bar"))); + + assert!(!filter.match_req(&req("/bar"))); + assert!(!filter.match_req(&req("/foo/baz"))); + assert!(!filter.match_req(&req("/foo/bar/baz"))); + } + + #[test] + fn match_req_wildcard_suffix_matches_descendants() { + let filter = parse_path_filter("/foo/*").unwrap(); + + // Depending on PathMatcher semantics, "/foo/*" usually matches "/foo/" + // and deeper, but not "/foo" itself. + assert!(!filter.match_req(&req("/foo"))); + assert!(!filter.match_req(&req("/foo/"))); // '*' matches something, trailing slashes do not count + assert!(filter.match_req(&req("/foo/bar"))); + assert!(filter.match_req(&req("/foo/bar/baz"))); + + assert!(!filter.match_req(&req("/"))); + assert!(!filter.match_req(&req("/bar"))); + assert!(!filter.match_req(&req("/foobar"))); + } + + #[test] + fn match_req_path_params_single_segment() { + let filter = parse_path_filter("/foo/{bar}/baz").unwrap(); + + assert!(filter.match_req(&req("/foo/x/baz"))); + assert!(filter.match_req(&req("/foo/123/baz"))); + assert!(filter.match_req(&req("/foo/some-value/baz"))); + + assert!(!filter.match_req(&req("/foo/x"))); + assert!(!filter.match_req(&req("/foo/x/baz/qux"))); + assert!(!filter.match_req(&req("/foo/x/qux"))); + assert!(!filter.match_req(&req("/foo//baz"))); + } + + #[test] + fn match_req_path_params_with_wildcard_suffix() { + let filter = parse_path_filter("/foo/{bar}/*").unwrap(); + + // Must have at least one segment after the param to satisfy the trailing /* + assert!(!filter.match_req(&req("/foo/x"))); + assert!(!filter.match_req(&req("/foo/x/"))); // '*' expects something + assert!(filter.match_req(&req("/foo/x/baz"))); + assert!(filter.match_req(&req("/foo/x/baz/qux"))); + + assert!(!filter.match_req(&req("/foo"))); + assert!(!filter.match_req(&req("/bar/x/baz"))); + } + + #[test] + fn match_req_any_of_multiple_matchers() { + let filter = parse_path_filter("/foo,/bar/*,/baz/{id}").unwrap(); + + assert!(filter.match_req(&req("/foo"))); + assert!(filter.match_req(&req("/bar/x"))); + assert!(filter.match_req(&req("/bar/x/y"))); + assert!(filter.match_req(&req("/baz/123"))); + + assert!(!filter.match_req(&req("/bar"))); + assert!(!filter.match_req(&req("/baz"))); + assert!(!filter.match_req(&req("/qux"))); + } + + #[test] + fn match_req_uses_only_uri_path_not_query() { + let filter = parse_path_filter("/foo").unwrap(); + + assert!(filter.match_req(&req("/foo?x=y"))); + assert!(!filter.match_req(&req("/foo/bar?x=y"))); + } +} diff --git a/proxy_netbench/src/cmd/emulate/filters/range.rs b/proxy_netbench/src/cmd/emulate/filters/range.rs new file mode 100644 index 00000000..3348e4cb --- /dev/null +++ b/proxy_netbench/src/cmd/emulate/filters/range.rs @@ -0,0 +1,213 @@ +use std::str::FromStr; + +use rama::error::{ErrorContext as _, OpaqueError}; + +#[derive(Debug, Clone)] +pub struct RangeFilter { + idx: usize, + max: usize, +} + +impl RangeFilter { + pub(super) fn new_single() -> Self { + Self { idx: 0, max: 0 } + } + + pub(super) fn new_infinite() -> Self { + Self { + idx: 0, + max: RANGE_MAX.saturating_sub(1), + } + } + + /// Returns true if the filter is still within bounds, + /// and false otherwise (meaning stop iterating). + pub(super) fn advance(&mut self) -> bool { + if self.idx > self.max { + return false; + } + + self.idx += 1; + true + } +} + +/// Arbitrary max limit to avoid too large runs. +const RANGE_MAX: usize = 32_000; + +impl FromStr for RangeFilter { + type Err = OpaqueError; + + fn from_str(s: &str) -> Result { + let trimmed_s = s.trim(); + if trimmed_s.is_empty() { + return Err(OpaqueError::from_display( + "empty string is not a valid range", + )); + } + + let Some((first, second)) = trimmed_s.split_once("..") else { + let max: usize = trimmed_s.parse().context("parse range as max value")?; + if max == 0 { + return Err(OpaqueError::from_display( + "MAX value has to be greater than zero (0)", + )); + } + return Ok(RangeFilter { + idx: 0, + max: max - 1, + }); + }; + + let min = if first.is_empty() { + 0 + } else { + first.parse().context("parse min value")? + }; + + let max = if second.is_empty() { + min + RANGE_MAX + } else { + let (second_trimmed, is_inclusive) = second + .strip_prefix('=') + .map(|s| (s, true)) + .unwrap_or((second, false)); + + let mut max: usize = second_trimmed.parse().context("parse max value")?; + if !is_inclusive { + max = max.saturating_sub(1); + } + + if max < min { + return Err(OpaqueError::from_display( + "MAX value has to be greater than or equal to MIN value", + )); + } + + let diff = max - min; + if diff > RANGE_MAX { + return Err(OpaqueError::from_display( + "range overflow: max iterations of {RANGE_MAX} reached", + )); + } + + max + }; + + Ok(RangeFilter { idx: min, max }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ok(input: &str, min: usize, max: usize) { + let parsed: RangeFilter = input.parse().unwrap_or_else(|e| { + panic!("expected Ok for input {input:?}, got Err: {e}\nerror debug: {e:?}") + }); + assert_eq!( + parsed.idx, min, + "idx should always start at min for input {input:?}" + ); + assert_eq!(parsed.max, max, "max mismatch for input {input:?}"); + } + + fn err_contains(input: &str, needle: &str) { + let err = input.parse::().unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains(needle), + "for input {input:?}, expected error containing {needle:?}, got: {msg:?}\nerror debug: {err:?}" + ); + } + + #[test] + fn range_filter_from_str_edge_cases() { + // No ".." means treat it as a max value, with min fixed at 0 + ok("1", 0, 0); + ok(" 12 ", 0, 11); + err_contains("0", "MAX value has to be greater than zero"); + err_contains(" 0 ", "MAX value has to be greater than zero"); + err_contains("nope", "parse range as max value"); + + // Basic range parsing + ok("0..0", 0, 0); // exclusive end, saturating_sub keeps it 0 + ok("0..1", 0, 0); // exclusive end, 1 becomes 0 + ok("0..=0", 0, 0); // inclusive end + ok("0..=1", 0, 1); // inclusive end + ok(" 3..=5 ", 3, 5); + ok("3..5", 3, 4); // exclusive end + + // Missing min defaults to 0 + ok("..5", 0, 4); + ok("..=5", 0, 5); + + // Missing max defaults to min + RANGE_MAX + ok("..", 0, RANGE_MAX); + ok("5..", 5, 5 + RANGE_MAX); + + // Inclusive marker with missing max still means "missing max" + // because the code only checks '=' when second is non empty, + // so "5..=" will attempt to parse an empty string as max and fail + err_contains("5..=", "parse max value"); + + // Validation errors + err_contains( + "10..5", + "MAX value has to be greater than or equal to MIN value", + ); + err_contains( + "10..=5", + "MAX value has to be greater than or equal to MIN value", + ); + + // Range overflow checks apply when max is explicitly provided + // diff is max - min after exclusivity handling + ok("0..=32000", 0, 32000); // diff == RANGE_MAX is allowed + err_contains("0..=32001", "range overflow"); + + ok("5..=32005", 5, 32005); // diff == RANGE_MAX is allowed + err_contains("5..=32006", "range overflow"); + + // Parse error contexts for each part + err_contains("a..5", "parse min value"); + err_contains("1..b", "parse max value"); + err_contains("1..=b", "parse max value"); + + // Weird but important: split_once("..") splits at the first ".." + // "1...2" becomes first "1", second ".2" and then max parse fails + err_contains("1...2", "parse max value"); + // same same + err_contains("1..2..3", "parse max value"); + } + + #[test] + fn advance_stops_exactly_after_max() { + let mut rf = RangeFilter { max: 5, idx: 3 }; + + // idx = 3 + assert!(rf.advance()); + assert_eq!(rf.idx, 4); + + // idx = 4 + assert!(rf.advance()); + assert_eq!(rf.idx, 5); + + // idx = 5 + assert!(rf.advance()); + assert_eq!(rf.idx, 6); + + // idx = 6, now beyond max + assert!(!rf.advance()); + assert_eq!(rf.idx, 6); + } + + #[test] + fn advance_immediately_stops_when_already_out_of_bounds() { + let mut rf = RangeFilter { max: 2, idx: 3 }; + + assert!(!rf.advance()); + assert_eq!(rf.idx, 3); + } +} diff --git a/proxy_netbench/src/cmd/emulate/mod.rs b/proxy_netbench/src/cmd/emulate/mod.rs index c63e72a6..20e4c0f4 100644 --- a/proxy_netbench/src/cmd/emulate/mod.rs +++ b/proxy_netbench/src/cmd/emulate/mod.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{path::PathBuf, time::Duration}; use rama::{ Service, @@ -15,10 +15,17 @@ use safechain_proxy_lib::{ }; mod client; +mod filters; mod source; use self::{ client::Client, + filters::{ + SourceFilter, + domain::{DomainFilter, parse_domain_filter}, + path::{PathFilter, parse_path_filter}, + range::RangeFilter, + }, source::{Source, SourceKind}, }; @@ -32,8 +39,47 @@ pub struct EmulateCommand { /// instead of emulating return a curl request #[arg(long, default_value_t = false)] curl: bool, + + #[arg(long)] + /// post-filtered request range + /// + /// examples: + /// + /// - '3' + /// - '..3' + /// - '..=3' + /// - '1..2' + /// - '1..=2' + range: Option, + + #[arg(long, value_parser = parse_domain_filter)] + /// domains to filter on (by default all domains are allowed) + /// + /// examples: + /// + /// - 'example.com' + /// - 'foo.example.com' + /// - '*.example.com' + domains: Option, + + #[arg(long, value_parser = parse_path_filter)] + /// paths to filter on (by default all paths are allowed) + /// + /// examples: + /// + /// - '/' + /// - '/foo/*' + /// - '/user/{name}/foo' + paths: Option, + + /// artificial delay in between req executions + #[arg(long, value_name = "SECONDS", default_value_t = 0.)] + gap: f64, + + /// caps how long this command is allowed to run for (min 1 second) + #[arg(long, value_name = "SECONDS", default_value_t = 30.)] + timeout: f64, // TODO: - // - support filters: range, domain, path, ... // - support export success requests to a file under dir (to create test cases from this) // - add under firewall tests using such requests to ensure they do block :) // - write diagnostics docs @@ -52,11 +98,22 @@ pub async fn exec( ) .await?; - let source = run_future_unless_cancelled( - &guard, - "create source", - Source::try_new(args.source, data_storage.clone()), - ) + let (source, source_filter) = run_future_unless_cancelled(&guard, "create source", { + let data_storage_clone = data_storage.clone(); + async move { + let source = Source::try_new(args.source, data_storage_clone).await?; + match source { + har_src @ Source::Har { .. } => Ok(( + har_src, + SourceFilter::new_har_filter(args.range, args.domains, args.paths), + )), + synthetic_src @ Source::Synthetic(_) => Ok(( + synthetic_src, + SourceFilter::new_synthetic_filter(args.range, args.domains, args.paths), + )), + } + } + }) .await?; let client = run_future_unless_cancelled( @@ -66,7 +123,19 @@ pub async fn exec( ) .await?; - exec_emulate_loop(&guard, client.clone(), source, args.curl).await?; + tokio::time::timeout( + Duration::from_secs_f64(args.timeout.max(1.)), + exec_emulate_loop( + &guard, + client.clone(), + source, + source_filter, + args.curl, + args.gap, + ), + ) + .await + .context("exec timeout")??; client.wait_for_blocked_events().await?; for blocked_event in client.blocked_events() { @@ -90,7 +159,9 @@ async fn exec_emulate_loop( guard: &ShutdownGuard, client: Client, mut source: Source, + mut source_filter: SourceFilter, curl: bool, + gap_secs: f64, ) -> Result<(), OpaqueError> { loop { let Some(req) = @@ -100,6 +171,14 @@ async fn exec_emulate_loop( return Ok(()); }; + if !source_filter.filter(&req) { + return Ok(()); + } + + if gap_secs > 0. { + tokio::time::sleep(Duration::from_secs_f64(gap_secs)).await; + } + if curl { let (parts, body) = req.into_parts(); let bytes = body diff --git a/proxy_netbench/src/config/product.rs b/proxy_netbench/src/config/product.rs index ed909b5d..e33ca4a5 100644 --- a/proxy_netbench/src/config/product.rs +++ b/proxy_netbench/src/config/product.rs @@ -18,8 +18,11 @@ rama::utils::macros::enums::enum_builder! { } pub fn parse_product_values(input: &str) -> Result { - let result: Result>, _> = - input.split(",").map(|s| s.parse()).collect(); + let result: Result>, _> = input + .split(",") + .filter(|&s| !s.is_empty()) + .map(|s| s.parse()) + .collect(); match result { Ok(values) => NonEmptyVec::try_from(values).map_err(|err| err.to_string()), Err(err) => Err(err.to_string()), @@ -45,19 +48,6 @@ mod tests { #[test] fn test_parse_product_values() { for (input, expected) in [ - ( - "", - Some(non_empty_vec![QualityValue::new_value(Product::Unknown( - "".to_owned() - ))]), - ), - ( - ";q=0.42", - Some(non_empty_vec![QualityValue::new( - Product::Unknown("".to_owned()), - Quality::new_clamped(420) - )]), - ), ( "-", Some(non_empty_vec![QualityValue::new_value(Product::None)]), From a690ce8eaf4796be7c2184873a027f03cb4202fa Mon Sep 17 00:00:00 2001 From: glendc Date: Sat, 31 Jan 2026 23:56:26 +0100 Subject: [PATCH 49/52] add har request e2e tests (made with emulate cmd of netbench) --- justfile | 7 +- proxy/src/firewall/malware_list.rs | 26 +-- proxy/src/firewall/mod.rs | 22 ++- proxy/src/firewall/rule/pypi.rs | 4 + ..._not_install_this_package_002_0_1_0_tar_gz | 1 + ..._DonJayamne_DonJayamne_5_1_1_foo_vspackage | 1 + .../firewall/tests/blocked_requests/mod.rs | 59 +++++++ proxy/src/firewall/tests/mod.rs | 1 + proxy/src/http/filename.rs | 152 ++++++++++++++++++ proxy/src/http/headers.rs | 17 +- proxy/src/http/mod.rs | 5 +- proxy_netbench/src/cmd/emulate/export.rs | 83 ++++++++++ proxy_netbench/src/cmd/emulate/mod.rs | 52 +++++- proxy_netbench/src/cmd/emulate/source.rs | 2 +- 14 files changed, 399 insertions(+), 33 deletions(-) create mode 100644 proxy/src/firewall/tests/blocked_requests/har_files/https___files_pythonhosted_org_packages_source_d_do_not_install_this_package_002_do_not_install_this_package_002_0_1_0_tar_gz create mode 100644 proxy/src/firewall/tests/blocked_requests/har_files/https___netbench_foo_gallery_vsassets_io_files_DonJayamne_DonJayamne_5_1_1_foo_vspackage create mode 100644 proxy/src/firewall/tests/blocked_requests/mod.rs create mode 100644 proxy/src/firewall/tests/mod.rs create mode 100644 proxy/src/http/filename.rs create mode 100644 proxy_netbench/src/cmd/emulate/export.rs diff --git a/justfile b/justfile index c40c3515..db61a1b0 100644 --- a/justfile +++ b/justfile @@ -14,6 +14,10 @@ rust-qa: cargo nextest run --all-features --workspace just rust-fuzz-check +rust-test-ignored: + @cargo install cargo-nextest --locked + cargo nextest run --workspace --all-features --run-ignored=only + rust-fuzz-check: @cargo install cargo-fuzz cargo +nightly fuzz check --fuzz-dir ./proxy_fuzz @@ -22,8 +26,7 @@ rust-fuzz *ARGS: @cargo install cargo-fuzz cargo +nightly fuzz run --fuzz-dir ./proxy_fuzz -j 8 parse_pragmatic_semver_version -- -max_total_time=60 -rust-qa-full: rust-qa rust-fuzz - cargo nextest run --workspace --all-features --run-ignored=only +rust-qa-full: rust-qa rust-test-ignored rust-fuzz run-proxy *ARGS: mkdir -p .aikido/safechain-proxy diff --git a/proxy/src/firewall/malware_list.rs b/proxy/src/firewall/malware_list.rs index 0903f0fc..e0fec80c 100644 --- a/proxy/src/firewall/malware_list.rs +++ b/proxy/src/firewall/malware_list.rs @@ -17,7 +17,9 @@ use rand::Rng; use serde::{Deserialize, Serialize}; use tokio::time::Instant; -use crate::{firewall::version::PackageVersion, storage::SyncCompactDataStorage}; +use crate::{ + firewall::version::PackageVersion, http::uri_to_filename, storage::SyncCompactDataStorage, +}; pub trait MalwareListEntryFormatter: Send + Sync { /// Map a raw malware-list entry into the trie lookup key. @@ -71,7 +73,7 @@ impl RemoteMalwareList { { let entry_formatter = formatter_or_default(formatter); - let filename = url_to_filename(&uri); + let filename = uri_to_filename(&uri); let refresh_interval = Duration::from_mins(10); let client = RemoteMalwareListClient { uri, @@ -141,7 +143,7 @@ impl RemoteMalwareList { where C: Service, { - let filename = url_to_filename(&uri); + let filename = uri_to_filename(&uri); let refresh_interval = Duration::MAX; // not used for this function let client = RemoteMalwareListClient { uri, @@ -495,16 +497,6 @@ pub struct ListDataEntry { pub reason: Reason, } -/// Display [`Uri`] as a appropriate string for a file name, -/// replacing all non-alphanumeric ASCII characters as an underscore `_`. -fn url_to_filename(url: &Uri) -> ArcStr { - url.to_string() - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) - .collect::() - .into() -} - #[cfg(test)] mod tests { use rama::utils::str::arcstr::arcstr; @@ -514,14 +506,6 @@ mod tests { use super::*; - #[test] - #[tracing_test::traced_test] - fn test_url_to_filename() { - let url = Uri::from_static("http://example.com/foo?bar=baz&answer=42"); - let filename = url_to_filename(&url); - assert_eq!("http___example_com_foo_bar_baz_answer_42", filename); - } - #[test] #[tracing_test::traced_test] fn test_list_data_entry_without_version() { diff --git a/proxy/src/firewall/mod.rs b/proxy/src/firewall/mod.rs index 80320ed0..319442dd 100644 --- a/proxy/src/firewall/mod.rs +++ b/proxy/src/firewall/mod.rs @@ -1,7 +1,7 @@ use std::{sync::Arc, time::Duration}; use rama::{ - Layer as _, Service as _, + Layer as _, Service, error::{ErrorContext as _, OpaqueError}, graceful::ShutdownGuard, http::{ @@ -34,6 +34,9 @@ pub mod version; mod domain_matcher; mod pac; +#[cfg(test)] +mod tests; + pub use self::domain_matcher::DomainMatcher; use crate::{ @@ -47,7 +50,7 @@ pub struct Firewall { // NOTE: if we ever want to update these rules on the fly, // e.g. removing/adding them, we can ArcSwap these and have // a background task update these when needed.. - block_rules: Arc>, + block_rules: Arc<[self::rule::DynRule]>, notifier: Option, } @@ -89,6 +92,16 @@ impl Firewall { crate::client::WebClientConfig::without_overwrites(), )?; + Self::try_new_with_event_notifier_and_web_client(guard, data, notifier, inner_https_client) + .await + } + + pub async fn try_new_with_event_notifier_and_web_client( + guard: ShutdownGuard, + data: SyncCompactDataStorage, + notifier: Option, + inner_https_client: impl Service, + ) -> Result { let shared_remote_malware_client = ( MapResponseBodyLayer::new(Body::new), DecompressionLayer::new(), @@ -111,7 +124,7 @@ impl Firewall { .boxed(); Ok(Self { - block_rules: Arc::new(vec![ + block_rules: vec![ self::rule::vscode::RuleVSCode::try_new( guard.clone(), shared_remote_malware_client.clone(), @@ -140,7 +153,8 @@ impl Firewall { .await .context("create block rule: pypi")? .into_dyn(), - ]), + ] + .into(), notifier, }) } diff --git a/proxy/src/firewall/rule/pypi.rs b/proxy/src/firewall/rule/pypi.rs index 76379df0..fbf204a6 100644 --- a/proxy/src/firewall/rule/pypi.rs +++ b/proxy/src/firewall/rule/pypi.rs @@ -81,6 +81,10 @@ impl RulePyPI { fn is_blocked(&self, package_info: &PackageInfo) -> Result { let entries = self.remote_malware_list.find_entries(&package_info.name); let Some(entries) = entries.entries() else { + tracing::trace!( + "no malware entry found for pkg name: '{}'", + package_info.name + ); return Ok(false); }; diff --git a/proxy/src/firewall/tests/blocked_requests/har_files/https___files_pythonhosted_org_packages_source_d_do_not_install_this_package_002_do_not_install_this_package_002_0_1_0_tar_gz b/proxy/src/firewall/tests/blocked_requests/har_files/https___files_pythonhosted_org_packages_source_d_do_not_install_this_package_002_do_not_install_this_package_002_0_1_0_tar_gz new file mode 100644 index 00000000..38ecfe1b --- /dev/null +++ b/proxy/src/firewall/tests/blocked_requests/har_files/https___files_pythonhosted_org_packages_source_d_do_not_install_this_package_002_do_not_install_this_package_002_0_1_0_tar_gz @@ -0,0 +1 @@ +{"method":"GET","url":"https://files.pythonhosted.org/packages/source/d/do-not-install-this-package-002/do-not-install-this-package-002-0.1.0.tar.gz","httpVersion":"1.1","cookies":[],"headers":[],"queryString":[],"postData":null,"headersSize":-1,"bodySize":0,"comment":null} \ No newline at end of file diff --git a/proxy/src/firewall/tests/blocked_requests/har_files/https___netbench_foo_gallery_vsassets_io_files_DonJayamne_DonJayamne_5_1_1_foo_vspackage b/proxy/src/firewall/tests/blocked_requests/har_files/https___netbench_foo_gallery_vsassets_io_files_DonJayamne_DonJayamne_5_1_1_foo_vspackage new file mode 100644 index 00000000..d7739068 --- /dev/null +++ b/proxy/src/firewall/tests/blocked_requests/har_files/https___netbench_foo_gallery_vsassets_io_files_DonJayamne_DonJayamne_5_1_1_foo_vspackage @@ -0,0 +1 @@ +{"method":"GET","url":"https://netbench-foo.gallery.vsassets.io/files/DonJayamne/DonJayamne/5.1.1/foo/vspackage","httpVersion":"1.1","cookies":[],"headers":[],"queryString":[],"postData":null,"headersSize":-1,"bodySize":0,"comment":null} \ No newline at end of file diff --git a/proxy/src/firewall/tests/blocked_requests/mod.rs b/proxy/src/firewall/tests/blocked_requests/mod.rs new file mode 100644 index 00000000..1e5e538c --- /dev/null +++ b/proxy/src/firewall/tests/blocked_requests/mod.rs @@ -0,0 +1,59 @@ +use rama::{ + graceful::Shutdown, + http::{client::EasyHttpWebClient, layer::har}, + rt::Executor, + telemetry::tracing, + utils::include_dir, +}; + +use crate::{ + firewall::{Firewall, rule::RequestAction}, + storage::SyncCompactDataStorage, +}; + +#[tokio::test] +#[tracing_test::traced_test] +#[ignore] +async fn test_firewall_blocked_request() { + let data_dir = crate::test::tmp_dir::try_new("test_firewall_blocked_request").unwrap(); + tracing::info!("test_firewall_blocked_request all data stored under: {data_dir:?}"); + + let shutdown = Shutdown::new(std::future::pending::<()>()); + let data_storage = SyncCompactDataStorage::try_new(data_dir).unwrap(); + + let firewall = Firewall::try_new_with_event_notifier_and_web_client( + shutdown.guard(), + data_storage, + None, + EasyHttpWebClient::default_with_executor(Executor::graceful(shutdown.guard())), + ) + .await + .unwrap(); + + static HAR_FILES: include_dir::Dir = include_dir::include_dir!( + "$CARGO_MANIFEST_DIR/src/firewall/tests/blocked_requests/har_files" + ); + + for har_file in HAR_FILES + .entries() + .iter() + .filter_map(|entry| entry.as_file()) + { + tracing::info!( + "check if test file {} will be correctly blocked", + har_file.path().display(), + ); + test_firewall_blocked_request_inner(&firewall, har_file).await; + } +} + +async fn test_firewall_blocked_request_inner(firewall: &Firewall, file: &include_dir::File<'_>) { + let har_req: har::spec::Request = serde_json::from_slice(file.contents()).unwrap(); + let http_req = har_req.try_into().unwrap(); + let action = firewall.evaluate_request(http_req).await.unwrap(); + assert!( + matches!(action, RequestAction::Block(_)), + "check if test file {} will be correctly blocked", + file.path().display() + ); +} diff --git a/proxy/src/firewall/tests/mod.rs b/proxy/src/firewall/tests/mod.rs new file mode 100644 index 00000000..311ac688 --- /dev/null +++ b/proxy/src/firewall/tests/mod.rs @@ -0,0 +1 @@ +mod blocked_requests; diff --git a/proxy/src/http/filename.rs b/proxy/src/http/filename.rs new file mode 100644 index 00000000..7d587cf7 --- /dev/null +++ b/proxy/src/http/filename.rs @@ -0,0 +1,152 @@ +use std::borrow::Cow; + +use rama::{ + error::{ErrorContext as _, OpaqueError}, + http::{Request, Uri}, + net::http::RequestContext, + utils::str::arcstr::ArcStr, +}; + +pub fn try_req_to_filename(req: &Request) -> Result { + let uri = try_create_full_uri_for_req(req)?; + Ok(uri_to_filename(&uri)) +} + +fn try_create_full_uri_for_req(req: &Request) -> Result, OpaqueError> { + if req.uri().authority().is_some() { + return Ok(Cow::Borrowed(req.uri())); + } + + let request_ctx = RequestContext::try_from(req).context("create RequestContext from req")?; + + let mut uri_parts = req.uri().clone().into_parts(); + uri_parts.scheme = Some( + request_ctx + .protocol + .as_str() + .try_into() + .context("use RequestContext.protocol as http scheme")?, + ); + + let authority = if request_ctx.authority_has_default_port() { + request_ctx.authority.host.to_string() + } else { + request_ctx.authority.to_string() + }; + + uri_parts.authority = Some( + authority + .try_into() + .context("use RequestContext.authority as http authority")?, + ); + + Ok(Cow::Owned( + Uri::from_parts(uri_parts).context("create http uri from parts")?, + )) +} + +/// Display [`Uri`] as a appropriate string for a file name, +/// replacing all non-alphanumeric ASCII characters as an underscore `_`. +pub fn uri_to_filename(url: &Uri) -> ArcStr { + url.to_string() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::() + .into() +} + +#[cfg(test)] +mod tests { + use rama::http::header::HOST; + use std::fmt; + + use super::*; + + #[test] + fn test_url_to_filename() { + let url = Uri::from_static("http://example.com/foo?bar=baz&answer=42"); + let filename = uri_to_filename(&url); + assert_eq!("http___example_com_foo_bar_baz_answer_42", filename); + } + + fn err_contains(res: Result, needle: &str) { + let err = res.unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains(needle), + "expected error containing {needle:?}, got {msg:?}\nerror debug: {err:?}" + ); + } + + fn req_abs(uri: &'static str) -> Request<()> { + Request::builder().uri(uri).body(()).unwrap() + } + + fn req_rel(path: &'static str, host: &'static str) -> Request<()> { + Request::builder() + .uri(path) + .header(HOST, host) + .body(()) + .unwrap() + } + + #[test] + fn uri_to_filename_replaces_non_alnum_ascii() { + let url = Uri::from_static("http://example.com/foo?bar=baz&answer=42"); + let filename = uri_to_filename(&url); + assert_eq!("http___example_com_foo_bar_baz_answer_42", filename); + } + + #[test] + fn try_req_to_filename_with_absolute_uri_uses_uri_as_is() { + let req = req_abs("http://example.com/foo?bar=baz&answer=42"); + let filename = try_req_to_filename(&req).unwrap(); + + assert_eq!( + "http___example_com_foo_bar_baz_answer_42", + filename.as_str() + ); + } + + #[test] + fn try_req_to_filename_with_relative_uri_builds_full_uri_from_context() { + // RequestContext::try_from is expected to derive authority from Host for relative URIs. + let req = req_rel("/foo?bar=baz&answer=42", "example.com"); + + let filename = try_req_to_filename(&req).unwrap(); + + // The scheme comes from RequestContext.protocol, which is typically "http" in this setup. + assert_eq!( + "http___example_com_foo_bar_baz_answer_42", + filename.as_str() + ); + } + + #[test] + fn try_req_to_filename_strips_default_port_from_authority() { + // If the authority has the default port, the code drops it. + // For typical HTTP this means 80. + let req = req_rel("/foo", "example.com:80"); + + let filename = try_req_to_filename(&req).unwrap(); + + assert_eq!("http___example_com_foo", filename.as_str()); + } + + #[test] + fn try_req_to_filename_keeps_non_default_port_in_authority() { + let req = req_rel("/foo", "example.com:8080"); + + let filename = try_req_to_filename(&req).unwrap(); + + assert_eq!("http___example_com_8080_foo", filename.as_str()); + } + + #[test] + fn try_req_to_filename_errors_if_full_context_cannot_be_derived() { + // Relative URI without authority and without Host header. + let req = Request::builder().uri("/foo").body(()).unwrap(); + + err_contains(try_req_to_filename(&req), "create RequestContext from req"); + } +} diff --git a/proxy/src/http/headers.rs b/proxy/src/http/headers.rs index cc911c36..5a31a031 100644 --- a/proxy/src/http/headers.rs +++ b/proxy/src/http/headers.rs @@ -1,6 +1,8 @@ use rama::http::{ HeaderMap, HeaderName, HeaderValue, - header::{CACHE_CONTROL, ETAG, Entry, LAST_MODIFIED}, + header::{ + AUTHORIZATION, CACHE_CONTROL, COOKIE, ETAG, Entry, LAST_MODIFIED, PROXY_AUTHORIZATION, + }, headers::{HeaderEncode, TypedHeader}, }; use rama::telemetry::tracing; @@ -18,6 +20,19 @@ pub fn remove_cache_headers(headers: &mut HeaderMap) { } } +pub fn remove_sensitive_req_headers(headers: &mut HeaderMap) { + for header_name in [AUTHORIZATION, COOKIE, PROXY_AUTHORIZATION] { + match headers.entry(header_name) { + Entry::Occupied(entry) => { + let (key, values) = entry.remove_entry_mult(); + let removed = values.count(); + tracing::debug!(header = %key, removed, "removed sensitive (request) header values"); + } + Entry::Vacant(_) => {} + } + } +} + #[derive(Debug, Clone, Copy)] /// x-blocked-by http header that is used by the firewall /// in case a request was blocked. diff --git a/proxy/src/http/mod.rs b/proxy/src/http/mod.rs index 69da924c..0a58fdff 100644 --- a/proxy/src/http/mod.rs +++ b/proxy/src/http/mod.rs @@ -4,7 +4,10 @@ mod content_type; pub use content_type::KnownContentType; mod headers; -pub use headers::{BlockedByHeader, remove_cache_headers}; +pub use headers::{BlockedByHeader, remove_cache_headers, remove_sensitive_req_headers}; mod req_info; pub use req_info::try_get_domain_for_req; + +mod filename; +pub use filename::{try_req_to_filename, uri_to_filename}; diff --git a/proxy_netbench/src/cmd/emulate/export.rs b/proxy_netbench/src/cmd/emulate/export.rs new file mode 100644 index 00000000..f820511f --- /dev/null +++ b/proxy_netbench/src/cmd/emulate/export.rs @@ -0,0 +1,83 @@ +use std::path::PathBuf; + +use rama::{ + error::{ErrorContext as _, OpaqueError}, + http::{Body, Request, body::util::BodyExt, layer::har}, + telemetry::tracing, +}; +use safechain_proxy_lib::http::{remove_sensitive_req_headers, try_req_to_filename}; + +/// Exporter of requests as HAR files. +/// +/// Useful for creating test cases and diagnostics. +pub(super) struct Exporter { + dir: PathBuf, + preserve_sensitive_headers: bool, +} + +#[derive(Debug)] +pub(super) struct ExportArtifact { + req: har::spec::Request, + path: PathBuf, +} + +impl Exporter { + pub(super) async fn try_new( + dir: PathBuf, + preserve_sensitive_headers: bool, + ) -> Result { + tokio::fs::create_dir_all(&dir) + .await + .with_context(|| format!("create export directory at path '{}'", dir.display()))?; + tracing::info!(path = ?dir, "exporter directory ready to be used"); + + Ok(Self { + dir, + preserve_sensitive_headers, + }) + } + + pub(super) async fn prepare_export_artifact( + &self, + req: Request, + ) -> Result<(ExportArtifact, Request), OpaqueError> { + let (parts, body) = req.into_parts(); + let bytes = body + .collect() + .await + .context("collect req body as bytes")? + .to_bytes(); + + let har_req = if self.preserve_sensitive_headers { + har::spec::Request::from_http_request_parts(&parts, &bytes) + .context("create HAR request from http request parts")? + } else { + let mut mod_parts = parts.clone(); + remove_sensitive_req_headers(&mut mod_parts.headers); + har::spec::Request::from_http_request_parts(&mod_parts, &bytes) + .context("create HAR request from (filtered) http request parts")? + }; + + let req = Request::from_parts(parts, Body::from(bytes)); + + let basename = try_req_to_filename(&req).context("req to filename str")?; + let filename = self.dir.join(basename.as_str()); + + Ok(( + ExportArtifact { + req: har_req, + path: filename, + }, + req, + )) + } +} + +impl ExportArtifact { + pub(super) async fn export(&self) -> Result<(), OpaqueError> { + let v = serde_json::to_vec(&self.req).context("JSON serialize export req (HAR)")?; + tokio::fs::write(&self.path, &v) + .await + .context("write (JSON) HAR export req") + } +} diff --git a/proxy_netbench/src/cmd/emulate/mod.rs b/proxy_netbench/src/cmd/emulate/mod.rs index 20e4c0f4..912648f6 100644 --- a/proxy_netbench/src/cmd/emulate/mod.rs +++ b/proxy_netbench/src/cmd/emulate/mod.rs @@ -15,6 +15,7 @@ use safechain_proxy_lib::{ }; mod client; +mod export; mod filters; mod source; @@ -72,6 +73,17 @@ pub struct EmulateCommand { /// - '/user/{name}/foo' paths: Option, + #[arg(long)] + /// exports requests which succeeded to a unique file in the given dir (path) + export: Option, + + /// Preserve sensitive headers when exporting requests. + /// + /// WARNING this can contain sensitive information such as credentials and + /// similar derived info. + #[arg(long, default_value_t = false)] + preserve: bool, + /// artificial delay in between req executions #[arg(long, value_name = "SECONDS", default_value_t = 0.)] gap: f64, @@ -80,8 +92,6 @@ pub struct EmulateCommand { #[arg(long, value_name = "SECONDS", default_value_t = 30.)] timeout: f64, // TODO: - // - support export success requests to a file under dir (to create test cases from this) - // - add under firewall tests using such requests to ensure they do block :) // - write diagnostics docs // - apply last feedback aikibot } @@ -123,11 +133,19 @@ pub async fn exec( ) .await?; + let maybe_exporter = run_future_unless_cancelled( + &guard, + "maybe create (req) exporter", + maybe_create_exporter(args.export, args.preserve), + ) + .await?; + tokio::time::timeout( Duration::from_secs_f64(args.timeout.max(1.)), exec_emulate_loop( &guard, client.clone(), + maybe_exporter, source, source_filter, args.curl, @@ -158,6 +176,7 @@ pub async fn exec( async fn exec_emulate_loop( guard: &ShutdownGuard, client: Client, + maybe_exporter: Option, mut source: Source, mut source_filter: SourceFilter, curl: bool, @@ -179,6 +198,14 @@ async fn exec_emulate_loop( tokio::time::sleep(Duration::from_secs_f64(gap_secs)).await; } + let (maybe_artifact, req) = if let Some(exporter) = maybe_exporter.as_ref() { + tracing::debug!("prepare export artifect for req ({})", req.uri()); + let (artifact, req) = exporter.prepare_export_artifact(req).await?; + (Some(artifact), req) + } else { + (None, req) + }; + if curl { let (parts, body) = req.into_parts(); let bytes = body @@ -194,10 +221,29 @@ async fn exec_emulate_loop( continue; } - let _resp = client.serve(req).await?; + let resp = client.serve(req).await?; + + if let Some(artifact) = maybe_artifact + && (100..400).contains(&resp.status().as_u16()) + { + tracing::debug!("export req artifact for unexpected success"); + artifact.export().await.context("export req artifact")?; + } } } +async fn maybe_create_exporter( + dir: Option, + preserve_sensitive_headers: bool, +) -> Result, OpaqueError> { + let Some(dir) = dir else { + return Ok(None); + }; + + let exporter = self::export::Exporter::try_new(dir, preserve_sensitive_headers).await?; + Ok(Some(exporter)) +} + async fn create_data_storage(data: PathBuf) -> Result { tokio::fs::create_dir_all(&data) .await diff --git a/proxy_netbench/src/cmd/emulate/source.rs b/proxy_netbench/src/cmd/emulate/source.rs index dd475044..4794cfff 100644 --- a/proxy_netbench/src/cmd/emulate/source.rs +++ b/proxy_netbench/src/cmd/emulate/source.rs @@ -72,7 +72,7 @@ impl Source { ) -> Result { match self { Source::Synthetic(_) => Ok(( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::NOT_MODIFIED, "synthetic request was not blocked", ) .into_response()), From 3f94f47419ebca3801cdf0a533464356bd1e6ad4 Mon Sep 17 00:00:00 2001 From: glendc Date: Sun, 1 Feb 2026 00:49:23 +0100 Subject: [PATCH 50/52] improve code + separate proxy troubleshooting doc --- docs/netbench.md | 6 + docs/proxy.md | 84 +----------- docs/proxy/troubleshooting.md | 125 ++++++++++++++++++ proxy/src/client/transport.rs | 74 ++++++++++- proxy/src/firewall/domain_matcher.rs | 23 ++-- proxy/src/http/headers.rs | 19 +-- .../src/cmd/emulate/filters/domain.rs | 1 - proxy_netbench/src/cmd/emulate/filters/mod.rs | 19 ++- .../src/cmd/emulate/filters/path.rs | 1 - proxy_netbench/src/cmd/emulate/mod.rs | 6 +- proxy_netbench/src/cmd/run/mod.rs | 12 +- 11 files changed, 247 insertions(+), 123 deletions(-) create mode 100644 docs/proxy/troubleshooting.md diff --git a/docs/netbench.md b/docs/netbench.md index 547cb534..a7c29af6 100644 --- a/docs/netbench.md +++ b/docs/netbench.md @@ -242,3 +242,9 @@ just run-netbench-cli emulate vscode If all is well it will show both the blocked response as well as the captured blocked-event notification. + +The `emulate` command can also be used to replay HAR traffic, +and you can even use it to automatically export malware requests +that weren't blocked as new (regression) test cases. + +See `just run-netbench-cli emulate --help` for more information. diff --git a/docs/proxy.md b/docs/proxy.md index 89f2faf6..f1fb82cc 100644 --- a/docs/proxy.md +++ b/docs/proxy.md @@ -12,6 +12,7 @@ Other proxy docs: and specifically how to pass a user config when connecting to the safechain proxy. - [./proxy/pac.md](./proxy/pac.md): learn more about Proxy Auto Configuration and how the safechain proxy project supports this flow. +- [`./proxy/troubleshooting.md](./proxy/troubleshooting.md): proxy troubleshooting doc. Related docs: @@ -260,88 +261,7 @@ This makes it trivial to: ## Troubleshooting -### Port Already in Use - -If you get a "port already in use" error: -1. Try running without `--port` to let the OS assign an available port -2. Or choose a different port: `./safechain-proxy --port 8080` - -### Proxy Not Working - -1. Verify the proxy is running and note the port from the console output -2. Check your client is configured with the correct port -3. Ensure firewall settings allow connections to the proxy - -To verify that the proxy is correctly configured via system settings, a PAC file, or a client specific configuration, -try accessing the pseudo domain `proxy.safechain.internal`. - -- If you can reach it over the `http://` scheme, the proxy is correctly configured and running. -- If you can also reach it over the `https://` scheme, the proxy root CA is trusted. - -For any HTTPS traffic that is MITM’d by the proxy, you should also observe that the server certificate -is signed by the proxy root CA rather than the original CA for that server -(for example Let’s Encrypt). - -### Proxied traffic fails - -If proxied traffic is not working as expected, try the following steps. - -- Check the logs if possible - stderr by default, or the configured output file when using the `--output` argument. - - [Verbose logging](#verbose-logging) can help narrow down the issue further, - especially when well defined directives are in use. -- Ensure that [HAR support](#har-support) is enabled and inspect the output - to see whether anything looks incorrect. - -If none of the above provides useful insight, you may need to escalate to more advanced tooling, -such as [Wireshark](https://www.wireshark.org/). - -The proxy supports SSL key logging to a file, which is required for Wireshark -to decrypt encrypted traffic on both the ingress and egress side. -Set the environment variable `SSLKEYLOGFILE=` to enable this. - -On macOS, there is an additional trick that can make it easier to identify -which traffic belongs to which process. - -```bash -sudo tcpdump -i pktap,all -k -w - \ - | /Applications/Wireshark.app/Contents/MacOS/Wireshark -k -i - -``` - -Using the special `pktap` interface on macOS, you can add the following columns -in Wireshark to display process information. - -``` -frame.darwin.process_info.pname -frame.darwin.process_info.pid -``` - -All traffic that passes through the proxy can then be identified with the filter. - -``` -frame.darwin.process_info.pname = safechain_proxy -``` - -This approach is not the simplest, but it is usually sufficient to determine -why traffic fails when routed through the proxy while succeeding when connecting -directly to the target services. - -### Verbose Logging - -Enable debug (or trace even) logging to troubleshoot issues: - -```bash -# macOS/Linux -RUST_LOG=debug ./safechain-proxy - -# Windows (Command Prompt) -set RUST_LOG=trace -safechain-proxy.exe - -# Windows (PowerShell) -$env:RUST_LOG = "debug,safechain_proxy=trace" -.\safechain-proxy.exe -``` +See [`./proxy/troubleshooting.md`](./proxy/troubleshooting.md). ## Stopping the Proxy diff --git a/docs/proxy/troubleshooting.md b/docs/proxy/troubleshooting.md new file mode 100644 index 00000000..c951d775 --- /dev/null +++ b/docs/proxy/troubleshooting.md @@ -0,0 +1,125 @@ +# Proxy Troubleshooting + +## Problems + +### Port Already in Use + +If you get a "port already in use" error: +- Try running without `--port` to let the OS assign an available port +- Or choose a different port: `./safechain-proxy --port 8080` + +### Proxy Not Working + +1. Verify the proxy is running and note the port from the console output +2. Check your client is configured with the correct port +3. Ensure firewall settings allow connections to the proxy + +To verify that the proxy is correctly configured via system settings, a PAC file, or a client specific configuration, +try accessing the pseudo domain `proxy.safechain.internal`. + +- If you can reach it over the `http://` scheme, the proxy is correctly configured and running. +- If you can also reach it over the `https://` scheme, the proxy root CA is trusted. + +For any HTTPS traffic that is MITM’d by the proxy, you should also observe that the server certificate +is signed by the proxy root CA rather than the original CA for that server +(for example Let’s Encrypt). + +### Proxied traffic fails + +If proxied traffic is not working as expected, try the following steps. + +- Check the logs if possible + stderr by default, or the configured output file when using the `--output` argument. + - [Verbose logging](#verbose-logging) can help narrow down the issue further, + especially when well defined directives are in use. +- Ensure that [HAR support](#har-support) is enabled and inspect the output + to see whether anything looks incorrect. + +If none of the above provides useful insight, you may need to escalate to more advanced tooling, +such as [Wireshark](https://www.wireshark.org/). + +The proxy supports SSL key logging to a file, which is required for Wireshark +to decrypt encrypted traffic on both the ingress and egress side. +Set the environment variable `SSLKEYLOGFILE=` to enable this. + +On macOS, there is an additional trick that can make it easier to identify +which traffic belongs to which process. + +```bash +sudo tcpdump -i pktap,all -k -w - \ + | /Applications/Wireshark.app/Contents/MacOS/Wireshark -k -i - +``` + +Using the special `pktap` interface on macOS, you can add the following columns +in Wireshark to display process information. + +``` +frame.darwin.process_info.pname +frame.darwin.process_info.pid +``` + +All traffic that passes through the proxy can then be identified with the filter. + +``` +frame.darwin.process_info.pname = safechain_proxy +``` + +This approach is not the simplest, but it is usually sufficient to determine +why traffic fails when routed through the proxy while succeeding when connecting +directly to the target services. + +### Verbose Logging + +Enable debug (or trace even) logging to troubleshoot issues: + +```bash +# macOS/Linux +RUST_LOG=debug ./safechain-proxy + +# Windows (Command Prompt) +set RUST_LOG=trace +safechain-proxy.exe + +# Windows (PowerShell) +$env:RUST_LOG = "debug,safechain_proxy=trace" +.\safechain-proxy.exe +``` + +'debug,safechain_proxy=trace' might as well be the default trace directive. + +### Malware request is not blocked + +In case your request is not blocked and you are certain that it should, you can: + +- look into the logs +- and/or use tools such as wireshark. + +You (or the agent) can however also make a HAR export of the failed traffic. +Using the `netbench emulate` command you can at this point also +emulate the request(s) in question and even automatically export them +as (new) request test cases so you can start to work on the issue. + +> For HAR export see the 'Har Support' chapter in [`../proxy.md`](../proxy.md), +> on how to enable and use it. You might need to use the proxy `--all` argument +> to MITM all traffic in case you do not even see the traffic via your usual MITM flow. +> +> Of course do feel free to use external tooling to create a HAR file, +> this should work as well. + +Once you have the har file you can run: + +```bash +just run-netbench-cli emulate \ + /tmp/my_recording.har \ + --export ./proxy/src/firewall/tests/blocked_requests/har_files \ + --domains 'pypi.org,files.pythonhosted.org' \ + --paths '/packages/*' +``` + +Adapt the command arguments to match your needs. +It will automatically export the newly found requests +that were NOT blocked as requests. + +Do make sure to filter properly as it will export _all_ +requests that were not blocked, which would be most of your HAR +file if you do not filter correctly. diff --git a/proxy/src/client/transport.rs b/proxy/src/client/transport.rs index 437c883e..41fddf24 100644 --- a/proxy/src/client/transport.rs +++ b/proxy/src/client/transport.rs @@ -1,11 +1,10 @@ use rama::{ - dns::GlobalDnsResolver, rt::Executor, tcp::{self, client::service::TcpStreamConnectorCloneFactory}, }; pub type TcpConnector = tcp::client::service::TcpConnector< - GlobalDnsResolver, + DnsResolver, TcpStreamConnectorCloneFactory, >; @@ -16,7 +15,9 @@ pub struct TcpConnectorConfig { } pub fn new_tcp_connector(exec: Executor, cfg: TcpConnectorConfig) -> TcpConnector { - tcp::client::service::TcpConnector::new(exec).with_connector(TcpStreamConnector::new(cfg)) + tcp::client::service::TcpConnector::new(exec) + .with_connector(TcpStreamConnector::new(cfg)) + .with_dns(new_dns_resolver()) } #[cfg(not(any(test, feature = "bench")))] @@ -44,6 +45,13 @@ mod production { } } + pub use ::rama::dns::GlobalDnsResolver as DnsResolver; + + #[inline(always)] + pub fn new_dns_resolver() -> DnsResolver { + DnsResolver::new() + } + #[inline(always)] pub fn new_tls_connector_config() -> Result { TlsConnectorData::try_new_http_auto() @@ -51,14 +59,16 @@ mod production { } #[cfg(not(any(test, feature = "bench")))] -pub use self::production::{TcpStreamConnector, new_tls_connector_config}; +pub use self::production::{ + DnsResolver, TcpStreamConnector, new_dns_resolver, new_tls_connector_config, +}; #[cfg(any(test, feature = "bench"))] mod bench { use std::sync::OnceLock; use rama::{ - error::{ErrorContext as _, OpaqueError}, + error::{BoxError, ErrorContext as _, OpaqueError}, net::address::SocketAddress, telemetry::tracing, tls::rustls::{ @@ -101,6 +111,57 @@ mod bench { } } + #[derive(Debug, Clone)] + pub struct DnsResolver(Option<::rama::dns::GlobalDnsResolver>); + + #[inline(always)] + pub fn new_dns_resolver() -> DnsResolver { + if is_eggress_address_overwritten() { + DnsResolver(None) + } else { + DnsResolver(Some(::rama::dns::GlobalDnsResolver::new())) + } + } + + impl ::rama::dns::DnsResolver for DnsResolver { + type Error = BoxError; + + async fn txt_lookup( + &self, + domain: rama::net::address::Domain, + ) -> Result>, Self::Error> { + if let Some(resolver) = self.0.as_ref() { + resolver.txt_lookup(domain).await + } else { + Ok(Vec::default()) + } + } + + async fn ipv4_lookup( + &self, + domain: rama::net::address::Domain, + ) -> Result, Self::Error> { + if let Some(resolver) = self.0.as_ref() { + resolver.ipv4_lookup(domain).await + } else { + // dummy value, we do not connect to it anyway + Ok(vec![std::net::Ipv4Addr::LOCALHOST]) + } + } + + async fn ipv6_lookup( + &self, + domain: rama::net::address::Domain, + ) -> Result, Self::Error> { + if let Some(resolver) = self.0.as_ref() { + resolver.ipv6_lookup(domain).await + } else { + // dummy value, we do not connect to it anyway + Ok(vec![std::net::Ipv6Addr::LOCALHOST]) + } + } + } + impl rama::tcp::client::TcpStreamConnector for TcpStreamConnector { type Error = std::io::Error; @@ -135,5 +196,6 @@ mod bench { #[cfg(any(test, feature = "bench"))] pub use self::bench::{ - TcpStreamConnector, new_tls_connector_config, try_set_egress_address_overwrite, + DnsResolver, TcpStreamConnector, new_dns_resolver, new_tls_connector_config, + try_set_egress_address_overwrite, }; diff --git a/proxy/src/firewall/domain_matcher.rs b/proxy/src/firewall/domain_matcher.rs index 0a3e6200..51483314 100644 --- a/proxy/src/firewall/domain_matcher.rs +++ b/proxy/src/firewall/domain_matcher.rs @@ -32,18 +32,19 @@ impl FromIterator for DomainMatcher { && let Ok(_) = parent.try_as_wildcard() { domains.insert_domain(parent, DomainAllowMode::Parent); - } else { - if domains - .match_parent(&domain) - .map(|m| *m.value == DomainAllowMode::Parent) - .unwrap_or_default() - { - // ignore exact mode if already a parent-mode exists for the key - // in order to prevent accidental collisions. - continue; - } - domains.insert_domain(domain, DomainAllowMode::Exact); + continue; } + + if domains + .match_parent(&domain) + .map(|m| *m.value == DomainAllowMode::Parent) + .unwrap_or_default() + { + // ignore exact mode if already a parent-mode exists for the key + // in order to prevent accidental collisions. + continue; + } + domains.insert_domain(domain, DomainAllowMode::Exact); } Self(domains) } diff --git a/proxy/src/http/headers.rs b/proxy/src/http/headers.rs index 5a31a031..9f5eeb9e 100644 --- a/proxy/src/http/headers.rs +++ b/proxy/src/http/headers.rs @@ -8,25 +8,20 @@ use rama::http::{ use rama::telemetry::tracing; pub fn remove_cache_headers(headers: &mut HeaderMap) { - for header_name in [ETAG, LAST_MODIFIED, CACHE_CONTROL] { - match headers.entry(header_name) { - Entry::Occupied(entry) => { - let (key, values) = entry.remove_entry_mult(); - let removed = values.count(); - tracing::debug!(header = %key, removed, "removed cache header values"); - } - Entry::Vacant(_) => {} - } - } + remove_headers(headers, &[ETAG, LAST_MODIFIED, CACHE_CONTROL]); } pub fn remove_sensitive_req_headers(headers: &mut HeaderMap) { - for header_name in [AUTHORIZATION, COOKIE, PROXY_AUTHORIZATION] { + remove_headers(headers, &[AUTHORIZATION, COOKIE, PROXY_AUTHORIZATION]); +} + +fn remove_headers(headers: &mut HeaderMap, header_names: &[HeaderName]) { + for header_name in header_names { match headers.entry(header_name) { Entry::Occupied(entry) => { let (key, values) = entry.remove_entry_mult(); let removed = values.count(); - tracing::debug!(header = %key, removed, "removed sensitive (request) header values"); + tracing::debug!(header = %key, removed, "removed header values"); } Entry::Vacant(_) => {} } diff --git a/proxy_netbench/src/cmd/emulate/filters/domain.rs b/proxy_netbench/src/cmd/emulate/filters/domain.rs index 89f43064..48ab9c70 100644 --- a/proxy_netbench/src/cmd/emulate/filters/domain.rs +++ b/proxy_netbench/src/cmd/emulate/filters/domain.rs @@ -10,7 +10,6 @@ use safechain_proxy_lib::firewall::DomainMatcher; #[derive(Debug, Default, Clone)] pub struct DomainFilter(Option>); -/// clap arg parser pub fn parse_domain_filter(input: &str) -> Result { let domains_result: Result, _> = input .split(",") diff --git a/proxy_netbench/src/cmd/emulate/filters/mod.rs b/proxy_netbench/src/cmd/emulate/filters/mod.rs index 579c417b..1947958e 100644 --- a/proxy_netbench/src/cmd/emulate/filters/mod.rs +++ b/proxy_netbench/src/cmd/emulate/filters/mod.rs @@ -11,6 +11,13 @@ pub struct SourceFilter { path: Option, } +#[derive(Debug)] +pub enum FilterResult { + Continue, + Skip, + Done, +} + impl SourceFilter { pub fn new_synthetic_filter( range: Option, @@ -36,20 +43,24 @@ impl SourceFilter { } } - pub fn filter(&mut self, req: &Request) -> bool { + pub fn filter(&mut self, req: &Request) -> FilterResult { if let Some(domain_matcher) = self.domain.as_ref() && !domain_matcher.match_req(req) { - return false; + return FilterResult::Skip; } if let Some(path_matcher) = self.path.as_ref() && !path_matcher.match_req(req) { - return false; + return FilterResult::Skip; } // IMPORTANT: range is post-filtered! - self.range.advance() + if self.range.advance() { + FilterResult::Done + } else { + FilterResult::Continue + } } } diff --git a/proxy_netbench/src/cmd/emulate/filters/path.rs b/proxy_netbench/src/cmd/emulate/filters/path.rs index a1cea3ba..b84eaa38 100644 --- a/proxy_netbench/src/cmd/emulate/filters/path.rs +++ b/proxy_netbench/src/cmd/emulate/filters/path.rs @@ -5,7 +5,6 @@ use rama::http::{Request, matcher::PathMatcher}; #[derive(Debug, Clone)] pub struct PathFilter(Arc<[PathMatcher]>); -/// clap arg parser pub fn parse_path_filter(input: &str) -> Result { let path_matcher_result = input .split(",") diff --git a/proxy_netbench/src/cmd/emulate/mod.rs b/proxy_netbench/src/cmd/emulate/mod.rs index 912648f6..0f02a238 100644 --- a/proxy_netbench/src/cmd/emulate/mod.rs +++ b/proxy_netbench/src/cmd/emulate/mod.rs @@ -190,8 +190,10 @@ async fn exec_emulate_loop( return Ok(()); }; - if !source_filter.filter(&req) { - return Ok(()); + match source_filter.filter(&req) { + self::filters::FilterResult::Continue => (), + self::filters::FilterResult::Skip => continue, + self::filters::FilterResult::Done => return Ok(()), } if gap_secs > 0. { diff --git a/proxy_netbench/src/cmd/run/mod.rs b/proxy_netbench/src/cmd/run/mod.rs index cda6c4c7..b18bff0b 100644 --- a/proxy_netbench/src/cmd/run/mod.rs +++ b/proxy_netbench/src/cmd/run/mod.rs @@ -1,11 +1,11 @@ -use std::{path::PathBuf, sync::Arc, time::Duration}; +use std::{error::Error, path::PathBuf, sync::Arc, time::Duration}; use rama::{ Service as _, error::{ErrorContext as _, OpaqueError}, graceful::ShutdownGuard, http::{Request, Response, body::util::BodyExt, response::Parts}, - net::address::SocketAddress, + net::{address::SocketAddress, conn::is_connection_error}, rt::Executor, service::BoxService, telemetry::tracing, @@ -368,7 +368,7 @@ fn compute_outcome_for_client_result(result: Result) -> Requ match result { Ok(resp) => { let status = resp.status.as_u16(); - if (200..400).contains(&status) { + if (200..500).contains(&status) { RequestOutcome { ok: true, status: Some(status), @@ -385,7 +385,11 @@ fn compute_outcome_for_client_result(result: Result) -> Requ Err(err) => { tracing::debug!("non-http error: {err}"); RequestOutcome { - ok: false, + ok: err + .source() + .and_then(|e| e.downcast_ref::()) + .map(is_connection_error) + .unwrap_or_default(), status: None, failure: Some(FailureKind::Other), } From abc81a84696835c1c3e8a9b950ec3b4ace3c2ed7 Mon Sep 17 00:00:00 2001 From: glendc Date: Sun, 1 Feb 2026 00:55:05 +0100 Subject: [PATCH 51/52] fix transport bench (dns) + disallow unwraps/expects if you do use unwrap/expect you need to allow it and hopefully with a good reason --- proxy/src/firewall/events.rs | 1 + proxy/src/lib.rs | 6 ++++++ proxy_cli/src/main.rs | 6 ++++++ proxy_netbench/src/cmd/emulate/mod.rs | 3 --- proxy_netbench/src/cmd/run/mod.rs | 1 + proxy_netbench/src/cmd/run/requests/source/mock.rs | 8 ++++++-- proxy_netbench/src/http/malware.rs | 4 ++++ proxy_netbench/src/main.rs | 6 ++++++ 8 files changed, 30 insertions(+), 5 deletions(-) diff --git a/proxy/src/firewall/events.rs b/proxy/src/firewall/events.rs index 0735dd34..c6a8eb0a 100644 --- a/proxy/src/firewall/events.rs +++ b/proxy/src/firewall/events.rs @@ -43,6 +43,7 @@ fn now_unix_ms() -> u64 { static START: OnceLock<(Instant, u64)> = OnceLock::new(); let (start_instant, start_unix_ms) = START.get_or_init(|| { + #[allow(clippy::unwrap_used, reason = "we require system time at least once")] let unix_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() diff --git a/proxy/src/lib.rs b/proxy/src/lib.rs index a31067c7..3c5eb02b 100644 --- a/proxy/src/lib.rs +++ b/proxy/src/lib.rs @@ -3,6 +3,12 @@ //! This allows the code to also be shared where desired //! with developer tooling such as netbench. +#![cfg_attr( + not(test), + warn(clippy::print_stdout, clippy::dbg_macro), + deny(clippy::unwrap_used, clippy::expect_used) +)] + pub mod cli; pub mod client; pub mod diagnostics; diff --git a/proxy_cli/src/main.rs b/proxy_cli/src/main.rs index 90dc1f35..8064c3a2 100644 --- a/proxy_cli/src/main.rs +++ b/proxy_cli/src/main.rs @@ -1,3 +1,9 @@ +#![cfg_attr( + not(test), + warn(clippy::print_stdout, clippy::dbg_macro), + deny(clippy::unwrap_used, clippy::expect_used) +)] + use rama::{error::BoxError, graceful}; #[cfg(target_family = "unix")] diff --git a/proxy_netbench/src/cmd/emulate/mod.rs b/proxy_netbench/src/cmd/emulate/mod.rs index 0f02a238..9e42550f 100644 --- a/proxy_netbench/src/cmd/emulate/mod.rs +++ b/proxy_netbench/src/cmd/emulate/mod.rs @@ -91,9 +91,6 @@ pub struct EmulateCommand { /// caps how long this command is allowed to run for (min 1 second) #[arg(long, value_name = "SECONDS", default_value_t = 30.)] timeout: f64, - // TODO: - // - write diagnostics docs - // - apply last feedback aikibot } pub async fn exec( diff --git a/proxy_netbench/src/cmd/run/mod.rs b/proxy_netbench/src/cmd/run/mod.rs index b18bff0b..d7095be4 100644 --- a/proxy_netbench/src/cmd/run/mod.rs +++ b/proxy_netbench/src/cmd/run/mod.rs @@ -214,6 +214,7 @@ async fn serve_req_validate_resp_and_report_result( return; } guard_result = concurrency.acquire() => { + #[allow(clippy::expect_used, reason = "see expect msg")] guard_result.expect("to always be able to acquire a semaphore guard") } }; diff --git a/proxy_netbench/src/cmd/run/requests/source/mock.rs b/proxy_netbench/src/cmd/run/requests/source/mock.rs index 4063eef5..9b8d97d9 100644 --- a/proxy_netbench/src/cmd/run/requests/source/mock.rs +++ b/proxy_netbench/src/cmd/run/requests/source/mock.rs @@ -1,6 +1,10 @@ use std::collections::VecDeque; -use rama::{error::OpaqueError, http::Request, telemetry::tracing}; +use rama::{ + error::{ErrorContext as _, OpaqueError}, + http::Request, + telemetry::tracing, +}; use rand::distr::{Distribution as _, weighted::WeightedIndex}; use safechain_proxy_lib::storage; @@ -79,7 +83,7 @@ async fn rand_requests_inner( let mut requests = VecDeque::with_capacity(request_count); let weights: Vec<_> = products.iter().map(|p| p.quality.as_u16()).collect(); - let dist = WeightedIndex::new(&weights).unwrap(); + let dist = WeightedIndex::new(&weights).context("create weighted index")?; for _ in 0..request_count { let product = products[dist.sample(&mut rand::rng())].value.clone(); diff --git a/proxy_netbench/src/http/malware.rs b/proxy_netbench/src/http/malware.rs index eab3981d..a81394cc 100644 --- a/proxy_netbench/src/http/malware.rs +++ b/proxy_netbench/src/http/malware.rs @@ -48,6 +48,10 @@ fn shared_download_client() -> BoxService { TimeoutLayer::new(Duration::from_secs(60)), RetryLayer::new( ManagedPolicy::default().with_backoff( + #[allow( + clippy::expect_used, + reason = "parameters are static for which it is known to work" + )] ExponentialBackoff::new( Duration::from_millis(100), Duration::from_secs(30), diff --git a/proxy_netbench/src/main.rs b/proxy_netbench/src/main.rs index 2208089d..883bb62c 100644 --- a/proxy_netbench/src/main.rs +++ b/proxy_netbench/src/main.rs @@ -1,3 +1,9 @@ +#![cfg_attr( + not(test), + warn(clippy::dbg_macro), + deny(clippy::unwrap_used, clippy::expect_used) +)] + use std::{path::PathBuf, time::Duration}; use rama::{ From 7bcbb462709ce97659ff58fd5fb222afb40f5700 Mon Sep 17 00:00:00 2001 From: glendc Date: Sun, 1 Feb 2026 01:05:59 +0100 Subject: [PATCH 52/52] fix dns in bench to ensure for non-overwritten paths we do use global dns --- proxy/src/client/transport.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/proxy/src/client/transport.rs b/proxy/src/client/transport.rs index 41fddf24..379d36fe 100644 --- a/proxy/src/client/transport.rs +++ b/proxy/src/client/transport.rs @@ -8,7 +8,7 @@ pub type TcpConnector = tcp::client::service::TcpConnector< TcpStreamConnectorCloneFactory, >; -#[derive(Debug, Default)] +#[derive(Debug, Clone, Default)] pub struct TcpConnectorConfig { #[cfg(all(not(test), feature = "bench"))] pub do_not_allow_overwrite: bool, @@ -16,8 +16,8 @@ pub struct TcpConnectorConfig { pub fn new_tcp_connector(exec: Executor, cfg: TcpConnectorConfig) -> TcpConnector { tcp::client::service::TcpConnector::new(exec) - .with_connector(TcpStreamConnector::new(cfg)) - .with_dns(new_dns_resolver()) + .with_connector(TcpStreamConnector::new(cfg.clone())) + .with_dns(new_dns_resolver(cfg)) } #[cfg(not(any(test, feature = "bench")))] @@ -48,7 +48,7 @@ mod production { pub use ::rama::dns::GlobalDnsResolver as DnsResolver; #[inline(always)] - pub fn new_dns_resolver() -> DnsResolver { + pub fn new_dns_resolver(_cfg: super::TcpConnectorConfig) -> DnsResolver { DnsResolver::new() } @@ -115,7 +115,15 @@ mod bench { pub struct DnsResolver(Option<::rama::dns::GlobalDnsResolver>); #[inline(always)] - pub fn new_dns_resolver() -> DnsResolver { + pub fn new_dns_resolver(cfg: super::TcpConnectorConfig) -> DnsResolver { + tracing::trace!("DnsResolver w/ tcp cfg: {cfg:?}"); + + #[cfg(all(not(test), feature = "bench"))] + if cfg.do_not_allow_overwrite { + // if not overwritten we do need the real DNS.. + return DnsResolver(Some(::rama::dns::GlobalDnsResolver::new())); + } + if is_eggress_address_overwritten() { DnsResolver(None) } else {