diff --git a/AGENTS.md b/AGENTS.md index f558e33..fd17124 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,7 @@ metadata/update/DNS/TLS inspection modes, and executes requests via `src/http`. - Rust response body paging is controlled by `--pager auto|on|off` or `pager = ...`; `auto` routes terminal stdout through `less -FIRX`, `on` forces the pager, and `off` disables it. Image responses and output-file writes bypass the pager. - Custom/pre-resolved DNS observes timeout budgets before the reqwest client is built: `--connect-timeout` bounds DNS resolution when set, otherwise DNS uses the remaining `--timeout` budget, and DoH lookup clients receive the same budget. - Custom/pre-resolved DNS is scoped to the request URL; manual redirects that change scheme, host, or port rebuild the reqwest client and resolve the redirect target so `--dns-server`, `-vvv`, and `--timing` stay aligned with the actual target. +- Custom/pre-resolved DNS runs A and AAAA lookups concurrently for both UDP and DoH, preserving any successful records when the other family fails. - Custom UDP DNS uses random query IDs and applies a 5s per-query receive timeout when no request/connect timeout is available, so unresponsive UDP resolvers cannot hang indefinitely. - GitHub Actions run Cargo fmt, clippy, unit tests, and the Rust integration suite. Release builds Cargo archives named for the self-updater, Linux GNU binaries are built with a prebuilt `cargo-zigbuild` against an explicit glibc 2.28 floor, Windows release binaries use the static MSVC CRT, and each archive is uploaded with a SHA-256 sidecar. The release workflow also composes target-specific `RUSTFLAGS` with `--cfg reqwest_unstable`, which is required while reqwest HTTP/3 support is enabled. The release workflow supports manual dry runs via `workflow_dispatch`, uploading archives as workflow artifacts unless explicitly told to upload to an existing GitHub Release. Release builds set `FETCH_VERSION` from the release tag/manual version so the compiled binary reports the published or test version; `Cargo.toml` intentionally remains `0.0.0` unless crate publishing becomes a goal. Local builds derive `FETCH_VERSION` from a matching `v*` git tag, then `git describe`, then `v0.0.0-dev`. - Rust default config discovery on Windows honors `XDG_CONFIG_HOME/fetch/config` and `HOME/.config/fetch/config` before falling back to `AppData/fetch/config`; Windows mTLS integration fixtures use RSA test certificates to stay compatible with reqwest/rustls platform verification. diff --git a/src/dns/doh.rs b/src/dns/doh.rs index e3a4247..781d756 100644 --- a/src/dns/doh.rs +++ b/src/dns/doh.rs @@ -37,8 +37,11 @@ pub async fn lookup_doh( return Ok(vec![ip]); } - let a = lookup_doh_type(server_url, host, "A", DNS_TYPE_A, timeout).await; - let aaaa = lookup_doh_type(server_url, host, "AAAA", DNS_TYPE_AAAA, timeout).await; + let client = doh_client(timeout)?; + let (a, aaaa) = tokio::join!( + lookup_doh_type_with_client(&client, server_url, host, "A", DNS_TYPE_A), + lookup_doh_type_with_client(&client, server_url, host, "AAAA", DNS_TYPE_AAAA) + ); let mut addrs = Vec::new(); if let Ok(records) = &a { @@ -63,13 +66,27 @@ pub async fn lookup_doh_type( answer_type: u16, timeout: Option, ) -> Result, DnsError> { - let url = doh_query_url(server_url, host, dns_type); + let client = doh_client(timeout)?; + lookup_doh_type_with_client(&client, server_url, host, dns_type, answer_type).await +} +fn doh_client(timeout: Option) -> Result { let mut builder = reqwest::Client::builder().use_rustls_tls(); if let Some(timeout) = timeout { builder = builder.timeout(timeout); } - let client = builder.build().map_err(|err| DnsError(err.to_string()))?; + builder.build().map_err(|err| DnsError(err.to_string())) +} + +async fn lookup_doh_type_with_client( + client: &reqwest::Client, + server_url: &Url, + host: &str, + dns_type: &str, + answer_type: u16, +) -> Result, DnsError> { + let url = doh_query_url(server_url, host, dns_type); + let response = client .get(url) .header(ACCEPT, "application/dns-json") @@ -194,6 +211,7 @@ struct DohErrorResponse { mod tests { use super::*; use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; async fn start_test_server(handler: F) -> (Url, tokio::task::JoinHandle<()>) where @@ -243,6 +261,66 @@ mod tests { ) } + async fn start_delayed_test_server(delay: Duration) -> (Url, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = vec![0; 4096]; + let Ok(n) = stream + .readable() + .await + .and_then(|_| stream.try_read(&mut buf)) + else { + return; + }; + let request = String::from_utf8_lossy(&buf[..n]); + let first_line = request.lines().next().unwrap_or_default(); + let path = first_line.split_whitespace().nth(1).unwrap_or("/"); + let ty = path + .split_once('?') + .map(|(_, query)| query) + .unwrap_or_default() + .split('&') + .find_map(|part| part.strip_prefix("type=")) + .unwrap_or_default(); + + tokio::time::sleep(delay).await; + + let response = match ty { + "A" => http::Response::new( + r#"{"Status":0,"Answer":[{"type":1,"data":"127.0.0.1"}]}"#.to_string(), + ), + "AAAA" => http::Response::new( + r#"{"Status":0,"Answer":[{"type":28,"data":"::1"}]}"#.to_string(), + ), + _ => http::Response::builder() + .status(400) + .body(r#"{"error":"bad type"}"#.to_string()) + .unwrap(), + }; + let (parts, body) = response.into_parts(); + let status = parts.status.as_u16(); + let reason = parts.status.canonical_reason().unwrap_or(""); + let raw = format!( + "HTTP/1.1 {status} {reason}\r\ncontent-length: {}\r\ncontent-type: application/json\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.writable().await; + let _ = stream.try_write(raw.as_bytes()); + }); + } + }); + ( + Url::parse(&format!("http://{addr}/dns-query")).unwrap(), + task, + ) + } + #[tokio::test] async fn lookup_doh_returns_a_and_aaaa() { let queries = Arc::new(Mutex::new(Vec::new())); @@ -278,7 +356,30 @@ mod tests { addrs.iter().map(ToString::to_string).collect::>(), ["127.0.0.1", "::1"] ); - assert_eq!(queries.lock().unwrap().join(","), "A,AAAA"); + let mut queries = queries.lock().unwrap().clone(); + queries.sort(); + assert_eq!(queries, ["A", "AAAA"]); + task.abort(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn lookup_doh_queries_a_and_aaaa_concurrently() { + let delay = Duration::from_millis(250); + let (url, task) = start_delayed_test_server(delay).await; + + let _ = doh_client(None).unwrap(); + let start = Instant::now(); + let addrs = lookup_doh(&url, "example.com", None).await.unwrap(); + let elapsed = start.elapsed(); + + assert_eq!( + addrs.iter().map(ToString::to_string).collect::>(), + ["127.0.0.1", "::1"] + ); + assert!( + elapsed < Duration::from_millis(400), + "lookup took {elapsed:?}, expected parallel A/AAAA queries near {delay:?}" + ); task.abort(); } diff --git a/src/dns/resolver.rs b/src/dns/resolver.rs index bce4279..35de37d 100644 --- a/src/dns/resolver.rs +++ b/src/dns/resolver.rs @@ -37,8 +37,10 @@ pub async fn lookup_udp( } let timeout = udp_dns_timeout(timeout); - let a = lookup_udp_type(server_addr, host, DNS_TYPE_A, timeout).await; - let aaaa = lookup_udp_type(server_addr, host, DNS_TYPE_AAAA, timeout).await; + let (a, aaaa) = tokio::join!( + lookup_udp_type(server_addr, host, DNS_TYPE_A, timeout), + lookup_udp_type(server_addr, host, DNS_TYPE_AAAA, timeout) + ); let mut addrs = Vec::new(); if let Ok(records) = &a { @@ -292,9 +294,9 @@ fn rcode_name(status: i32) -> &'static str { mod tests { use super::*; use std::net::UdpSocket as StdUdpSocket; - use std::sync::{Arc, Mutex}; + use std::sync::{Arc, Barrier, Mutex}; use std::thread; - use std::time::Duration; + use std::time::{Duration, Instant}; #[tokio::test] async fn lookup_udp_returns_a_and_aaaa() { @@ -309,6 +311,29 @@ mod tests { stop(); } + #[tokio::test] + async fn lookup_udp_queries_a_and_aaaa_concurrently() { + let delay = Duration::from_millis(100); + let timeout = Duration::from_millis(700); + let (addr, stop) = start_delayed_udp_server(delay); + + let start = Instant::now(); + let addrs = lookup_udp(&addr, "example.com", Some(timeout)) + .await + .unwrap(); + let elapsed = start.elapsed(); + + assert_eq!( + addrs.iter().map(ToString::to_string).collect::>(), + ["127.0.0.1", "::1"] + ); + assert!( + elapsed < Duration::from_millis(450), + "lookup took {elapsed:?}, expected parallel A/AAAA queries near {delay:?}" + ); + stop(); + } + #[tokio::test] async fn lookup_udp_type_returns_ttl() { let (addr, stop) = start_udp_server(DnsServerMode::Success); @@ -446,6 +471,73 @@ mod tests { }) } + fn start_delayed_udp_server(delay: Duration) -> (String, impl FnOnce()) { + let socket = StdUdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + let addr = socket.local_addr().unwrap().to_string(); + let done = Arc::new(Mutex::new(false)); + let barrier = Arc::new(Barrier::new(2)); + let thread_done = done.clone(); + let handle = thread::spawn(move || { + let mut buf = [0u8; 512]; + loop { + if *thread_done.lock().unwrap() { + return; + } + let Ok((n, peer)) = socket.recv_from(&mut buf) else { + continue; + }; + if n < 12 { + continue; + } + let Ok(response_socket) = socket.try_clone() else { + continue; + }; + let query = buf[..n].to_vec(); + let worker_barrier = barrier.clone(); + thread::spawn(move || { + worker_barrier.wait(); + thread::sleep(delay); + + let query_type = read_question_type(&query).unwrap_or_default(); + let question_name_end = question_end(&query).unwrap_or(12); + let question_end = (question_name_end + 4).min(query.len()); + let mut response = Vec::new(); + response.extend_from_slice(&query[0..2]); + let answer_count = + u16::from(query_type == DNS_TYPE_A || query_type == DNS_TYPE_AAAA); + response.extend_from_slice(&0x8180u16.to_be_bytes()); + response.extend_from_slice(&1u16.to_be_bytes()); + response.extend_from_slice(&answer_count.to_be_bytes()); + response.extend_from_slice(&0u16.to_be_bytes()); + response.extend_from_slice(&0u16.to_be_bytes()); + response.extend_from_slice(&query[12..question_end]); + match query_type { + DNS_TYPE_A => write_answer(&mut response, DNS_TYPE_A, 42, &[127, 0, 0, 1]), + DNS_TYPE_AAAA => write_answer( + &mut response, + DNS_TYPE_AAAA, + 43, + &std::net::Ipv6Addr::LOCALHOST.octets(), + ), + _ => {} + } + let _ = response_socket.send_to(&response, peer); + }); + } + }); + + (addr, move || { + *done.lock().unwrap() = true; + let _ = StdUdpSocket::bind("127.0.0.1:0") + .unwrap() + .send_to(&[0], "127.0.0.1:9"); + handle.join().unwrap(); + }) + } + fn write_answer(response: &mut Vec, dns_type: u16, ttl: u32, data: &[u8]) { response.extend_from_slice(&[0xc0, 0x0c]); response.extend_from_slice(&dns_type.to_be_bytes()); diff --git a/src/http/client.rs b/src/http/client.rs index cbb9683..b72ac9f 100644 --- a/src/http/client.rs +++ b/src/http/client.rs @@ -158,18 +158,34 @@ async fn resolve_dns_for_client( ) -> Result, FetchError> { let resolve = resolve_dns_for_client_inner(cli, url, http_version, timeout); if let Some(timeout) = timeout { + let start = Instant::now(); match tokio::time::timeout(timeout, resolve).await { + Ok(Err(err)) if start.elapsed() >= timeout && is_timeout_error(&err) => { + Err(request_timeout_error(timeout)) + } Ok(result) => result, - Err(_) => Err(FetchError::Runtime(format!( - "request timed out after {}", - crate::http::format_go_duration(timeout) - ))), + Err(_) => Err(request_timeout_error(timeout)), } } else { resolve.await } } +fn request_timeout_error(timeout: Duration) -> FetchError { + FetchError::Runtime(format!( + "request timed out after {}", + crate::http::format_go_duration(timeout) + )) +} + +fn is_timeout_error(err: &FetchError) -> bool { + match err { + FetchError::Runtime(message) => message.contains("timed out"), + FetchError::Reqwest(err) => err.is_timeout(), + _ => false, + } +} + async fn resolve_dns_for_client_inner( cli: &Cli, url: &Url,