diff --git a/AGENTS.md b/AGENTS.md index 25d363f..90a11f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,14 @@ measures Connect + TTFB per subnet. `README.md` is what and why for people installing the app. This file is how to work here. +## Privacy + +Public trackers, pull requests, commit messages, tests, release notes, and +docs must never contain infrastructure details (internal IPs, resolver +addresses, VPN or network topology) or account data (subnet lists, +credentials). Describe failures generically — "the VPN resolver", "a stuck +subnet" — never the specific values. + ## Product One window, not resizable. One workflow: diff --git a/package.json b/package.json index 26eab10..6fd3ae6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxybench", - "version": "0.4.1", + "version": "0.4.2", "description": "Split HTTP proxy lists by subnet and measure Connect and TTFB", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index afd923f..025dff1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2789,7 +2789,7 @@ dependencies = [ [[package]] name = "proxybench" -version = "0.4.1" +version = "0.4.2" dependencies = [ "base64 0.22.1", "bytes", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bc20743..fc85c82 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxybench" -version = "0.4.1" +version = "0.4.2" description = "Split HTTP proxy lists by subnet and measure Connect and TTFB" edition = "2021" license = "MIT" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4875311..b8ca504 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -14,6 +14,7 @@ use crate::target::Target; const LAST_TARGET_FILE: &str = "last-target.txt"; const PROGRESS_EVENT: &str = "run-progress"; +const COUNTRIES_EVENT: &str = "countries-refreshed"; pub struct LastTarget(pub last_target::Store); @@ -65,6 +66,7 @@ pub async fn start_run( candidate.record_countries(&countries); inventory.0.save(&candidate.snapshot())?; *session = candidate; + let _ = app.emit(COUNTRIES_EVENT, &countries); } let window = app.clone(); let finished = run::probe_session(buckets, target, move |progress: Progress| { diff --git a/src-tauri/src/country.rs b/src-tauri/src/country.rs index d8dbf42..de6ed7f 100644 --- a/src-tauri/src/country.rs +++ b/src-tauri/src/country.rs @@ -4,15 +4,21 @@ use std::net::Ipv4Addr; use std::thread; use std::time::{Duration, Instant}; +use crate::dns; use crate::split::Subnet; const ENDPOINT: &str = "https://api.country.is/"; const TIMEOUT: Duration = Duration::from_secs(8); const BATCH_SIZE: usize = 100; const REQUEST_INTERVAL: Duration = Duration::from_millis(100); -const RETRY_DELAY: Duration = Duration::from_secs(1); +const RETRY_DELAY: Duration = Duration::from_millis(500); +const ROUND_DELAYS: [Duration; 3] = [ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(4), +]; const ATTEMPTS: usize = 3; -const ROUNDS: usize = 2; +const ROUNDS: usize = 4; #[derive(Debug, serde::Deserialize)] struct Lookup { @@ -36,7 +42,12 @@ impl fmt::Display for LookupError { } pub fn lookup(ips: &[Ipv4Addr]) -> HashMap { - lookup_with(ips, fetch) + let agent = fetch_agent(); + lookup_with(ips, |batch| fetch(&agent, batch)) +} + +fn fetch_agent() -> ureq::Agent { + ureq::AgentBuilder::new().resolver(dns::resolve).build() } fn lookup_with(ips: &[Ipv4Addr], mut send: F) -> HashMap @@ -61,10 +72,13 @@ where None => unresolved.push(batch), } } - if unresolved.is_empty() || round + 1 == ROUNDS { + if unresolved.is_empty() { break; } - thread::sleep(RETRY_DELAY); + match ROUND_DELAYS.get(round) { + Some(delay) => thread::sleep(*delay), + None => break, + } batches = unresolved; } countries @@ -116,9 +130,10 @@ fn wait_for_request(last_request: Option) { } } -fn fetch(ips: &[Ipv4Addr]) -> Result, LookupError> { +fn fetch(agent: &ureq::Agent, ips: &[Ipv4Addr]) -> Result, LookupError> { let ips: Vec<_> = ips.iter().map(Ipv4Addr::to_string).collect(); - let response = ureq::post(ENDPOINT) + let response = agent + .post(ENDPOINT) .timeout(TIMEOUT) .send_json(ips) .map_err(|_| LookupError::Http)?; @@ -291,14 +306,12 @@ mod tests { #[test] #[ignore = "hits the live api.country.is service"] fn lookup_resolves_live_subnets() { - let ips: Vec = ["51.146.191.0", "64.49.57.0", "43.251.2.0"] + let ips: Vec = ["140.82.121.3", "140.82.121.4", "140.82.121.5"] .iter() .map(|value| value.parse().unwrap()) .collect(); let countries = lookup(&ips); - assert!(countries.contains_key("51.146.191.0/24")); - assert!(countries.contains_key("64.49.57.0/24")); - assert!(countries.contains_key("43.251.2.0/24")); + assert!(countries.contains_key("140.82.121.0/24")); } #[test] diff --git a/src-tauri/src/dns.rs b/src-tauri/src/dns.rs new file mode 100644 index 0000000..deed16c --- /dev/null +++ b/src-tauri/src/dns.rs @@ -0,0 +1,258 @@ +use std::collections::BTreeMap; +use std::io; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, ToSocketAddrs}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +const PUBLIC_RESOLVERS: [&str; 2] = ["1.1.1.1:53", "8.8.8.8:53"]; +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(1); +const CACHE_TTL: Duration = Duration::from_secs(300); +const TYPE_A: u16 = 1; + +fn cache() -> &'static Mutex> { + static CACHE: Mutex> = Mutex::new(BTreeMap::new()); + &CACHE +} + +struct CachedAddresses { + at: Instant, + addresses: Vec, +} + +pub fn resolve(netloc: &str) -> io::Result> { + let (host, port) = split_netloc(netloc)?; + if let Some(cached) = cached(&host) { + return Ok(cached); + } + let usable = filter_usable(system_addresses(&host, port)); + let addresses = if usable.is_empty() { + query_public(&host, port) + } else { + usable + }; + if addresses.is_empty() { + Err(io::Error::other(format!( + "dns resolution failed for {host}" + ))) + } else { + remember(&host, &addresses); + Ok(addresses) + } +} + +fn cached(host: &str) -> Option> { + let cache = cache().lock().ok()?; + let cached = cache.get(host)?; + if cached.at.elapsed() < CACHE_TTL { + Some(cached.addresses.clone()) + } else { + None + } +} + +fn remember(host: &str, addresses: &[SocketAddr]) { + if let Ok(mut cache) = cache().lock() { + cache.insert( + host.to_string(), + CachedAddresses { + at: Instant::now(), + addresses: addresses.to_vec(), + }, + ); + } +} + +fn split_netloc(netloc: &str) -> io::Result<(String, u16)> { + let (host, port) = netloc + .rsplit_once(':') + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "netloc without port"))?; + let port = port + .parse() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid port"))?; + Ok((host.to_string(), port)) +} + +fn system_addresses(host: &str, port: u16) -> Vec { + format!("{host}:{port}") + .to_socket_addrs() + .map(|addrs| addrs.collect()) + .unwrap_or_default() +} + +fn filter_usable(addresses: Vec) -> Vec { + addresses + .into_iter() + .filter(|address| !address.ip().is_unspecified()) + .collect() +} + +fn query_public(host: &str, port: u16) -> Vec { + PUBLIC_RESOLVERS + .iter() + .filter_map(|resolver| { + query_a_records(host, resolver).map(|records| { + records + .into_iter() + .map(|ip| SocketAddr::new(ip, port)) + .collect::>() + }) + }) + .next() + .unwrap_or_default() +} + +fn query_a_records(host: &str, resolver: &str) -> Option> { + let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + socket.set_read_timeout(Some(RESPONSE_TIMEOUT)).ok()?; + let query = build_query(host)?; + socket.send_to(&query, resolver).ok()?; + let mut buffer = [0u8; 1024]; + let received = socket.recv(&mut buffer).ok()?; + parse_a_records(&buffer[..received]).filter(|records| !records.is_empty()) +} + +fn build_query(host: &str) -> Option> { + let mut query = vec![0x53, 0x1f, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + for label in host.split('.') { + let length = label.len(); + if length == 0 || length > 63 { + return None; + } + query.push(length as u8); + query.extend_from_slice(label.as_bytes()); + } + query.extend_from_slice(&[0x00, 0x00, 0x01, 0x00, 0x01]); + Some(query) +} + +fn parse_a_records(packet: &[u8]) -> Option> { + if packet.len() < 12 { + return None; + } + let answers = u16::from_be_bytes([packet[6], packet[7]]) as usize; + let mut offset = 12; + while offset < packet.len() && packet[offset] != 0 { + offset += packet[offset] as usize + 1; + } + offset += 5; + let mut records = Vec::new(); + for _ in 0..answers { + offset = skip_name(packet, offset)?; + if offset + 10 > packet.len() { + return None; + } + let record_type = u16::from_be_bytes([packet[offset], packet[offset + 1]]); + let record_length = u16::from_be_bytes([packet[offset + 8], packet[offset + 9]]) as usize; + let data_start = offset + 10; + if data_start + record_length > packet.len() { + return None; + } + if record_type == TYPE_A && record_length == 4 { + records.push(IpAddr::V4(Ipv4Addr::new( + packet[data_start], + packet[data_start + 1], + packet[data_start + 2], + packet[data_start + 3], + ))); + } + offset = data_start + record_length; + } + Some(records) +} + +fn skip_name(packet: &[u8], mut offset: usize) -> Option { + loop { + let byte = *packet.get(offset)?; + if byte & 0xC0 == 0xC0 { + return Some(offset + 2); + } + if byte == 0 { + return Some(offset + 1); + } + offset += byte as usize + 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cache_round_trips_addresses() { + let host = "cache.test"; + assert!(cached(host).is_none()); + remember( + host, + &[SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 443)], + ); + assert_eq!( + cached(host).unwrap(), + vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 443)] + ); + } + + #[test] + fn build_query_encodes_a_record_lookup() { + let query = build_query("api.country.is").unwrap(); + assert_eq!(&query[0..2], &[0x53, 0x1f]); + assert_eq!(&query[2..4], &[0x01, 0x00]); + assert_eq!( + &query[10..], + &[ + 3, b'a', b'p', b'i', 7, b'c', b'o', b'u', b'n', b't', b'r', b'y', 2, b'i', b's', 0, + 0, 1, 0, 1 + ] + ); + } + + #[test] + fn build_query_rejects_empty_labels() { + assert!(build_query("").is_none()); + } + + #[test] + fn parse_a_records_skips_cname_and_reads_addresses() { + let mut packet = vec![ + 0x53, 0x1f, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, + ]; + for label in "api.country.is".split('.') { + packet.push(label.len() as u8); + packet.extend_from_slice(label.as_bytes()); + } + packet.extend_from_slice(&[0x00, 0x00, 0x01, 0x00, 0x01]); + packet.extend_from_slice(&[ + 0xC0, 0x0C, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x7B, 0x00, 0x00, + ]); + packet.extend_from_slice(&[ + 0xC0, 0x0C, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x7B, 0x00, 0x04, 1, 2, 3, 4, + ]); + let records = parse_a_records(&packet).unwrap(); + assert_eq!(records, vec![IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))]); + } + + #[test] + fn parse_a_records_rejects_truncated_packets() { + assert!(parse_a_records(&[0x53, 0x1f]).is_none()); + assert!(parse_a_records(&[]).is_none()); + } + + #[test] + fn filter_usable_drops_unspecified_addresses() { + let addresses = vec![ + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 443), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 443), + SocketAddr::new(IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), 443), + ]; + let usable = filter_usable(addresses); + assert_eq!(usable.len(), 1); + assert_eq!(usable[0].ip(), IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))); + } + + #[test] + fn split_netloc_parses_host_and_port() { + let (host, port) = split_netloc("api.country.is:443").unwrap(); + assert_eq!(host, "api.country.is"); + assert_eq!(port, 443); + assert!(split_netloc("no-port").is_err()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8317e04..4647199 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod aycd; mod commands; mod country; +mod dns; mod export; mod import; mod inventory; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ab9cc6b..ed4b4be 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "proxybench", - "version": "0.4.1", + "version": "0.4.2", "identifier": "com.mathious.proxybench", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 04c70ef..232f441 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -97,6 +97,7 @@ let disposed = false; let stopDrop = () => {}; let stopProgress = () => {}; + let stopCountries = () => {}; void getCurrentWebview() .onDragDropEvent(async (event) => { if (event.payload.type === "enter" || event.payload.type === "over") { @@ -133,6 +134,22 @@ .catch((error) => { showNotice(String(error), true); }); + void listen>("countries-refreshed", (event) => { + rows = rows.map((row) => { + const country = event.payload[row.cidr]; + return country ? { ...row, country } : row; + }); + }) + .then((unlisten) => { + if (disposed) { + unlisten(); + return; + } + stopCountries = unlisten; + }) + .catch((error) => { + showNotice(String(error), true); + }); void getVersion() .then((value) => { version = value; @@ -160,6 +177,7 @@ disposed = true; stopDrop(); stopProgress(); + stopCountries(); if (noticeTimer) { clearTimeout(noticeTimer); noticeTimer = null;