diff --git a/AGENTS.md b/AGENTS.md index b5c8137..4897544 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,7 @@ metadata/update/DNS/TLS inspection modes, and executes requests via `src/http`. - Rust `--timing` enables DNS pre-resolution timing and wraps reqwest's connector service so the waterfall includes DNS, TCP, TTFB, and Body phases. reqwest does not currently expose a separate TLS handshake duration, so Rust reports the combined TCP/TLS connector phase as TCP timing. - Rust response bodies written to terminal stdout are routed through `less -FIRX` unless `--no-pager` or `no-pager = true` disables the pager; image responses, piped stdout, 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 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, and Dependabot tracks Cargo and Actions. Release builds set `FETCH_VERSION` from the release tag so the compiled binary reports the published 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. - `--copy` tees decoded response bodies to the system clipboard for both stdout and output-file responses, using platform clipboard commands (`pbcopy`, `wl-copy`, `xclip`, `xsel`, or `clip.exe`) and skipping clipboard writes when the decoded body exceeds 1 MiB. diff --git a/src/dns/inspect.rs b/src/dns/inspect.rs index 50ba2f8..3f5ba2c 100644 --- a/src/dns/inspect.rs +++ b/src/dns/inspect.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::io::IsTerminal; use std::net::{IpAddr, SocketAddr}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; use futures_util::future::join_all; use reqwest::header::{ACCEPT, USER_AGENT}; @@ -11,6 +11,7 @@ use url::Url; use crate::cli::Cli; use crate::core::{self, Printer, Sequence}; +use crate::dns::util::{dns_query_id, udp_dns_timeout}; use crate::error::{FetchError, write_error_with_color, write_warning_with_color}; const DNS_TYPE_A: u16 = 1; @@ -132,7 +133,7 @@ pub async fn execute(cli: &Cli) -> Result { let inspected = if let Some(timeout) = timeout { match tokio::time::timeout( timeout, - inspect_with_color(&url, cli.dns_server.as_deref(), use_color), + inspect_with_color(&url, cli.dns_server.as_deref(), use_color, Some(timeout)), ) .await { @@ -143,7 +144,7 @@ pub async fn execute(cli: &Cli) -> Result { ))), } } else { - inspect_with_color(&url, cli.dns_server.as_deref(), use_color).await + inspect_with_color(&url, cli.dns_server.as_deref(), use_color, None).await }; match inspected { @@ -160,13 +161,14 @@ pub async fn execute(cli: &Cli) -> Result { #[cfg(test)] async fn inspect(url: &Url, dns_server: Option<&str>) -> Result { - inspect_with_color(url, dns_server, false).await + inspect_with_color(url, dns_server, false, None).await } async fn inspect_with_color( url: &Url, dns_server: Option<&str>, use_color: bool, + timeout: Option, ) -> Result { let host = url .host_str() @@ -185,7 +187,7 @@ async fn inspect_with_color( )); } - let result = lookup(host, target, start).await?; + let result = lookup(host, target, start, timeout).await?; Ok(render_with_color(&result, use_color)) } @@ -193,6 +195,7 @@ async fn lookup( host: &str, target: ResolverTarget, start: Instant, + timeout: Option, ) -> Result { let mut out = Inspection { host: host.to_string(), @@ -216,13 +219,14 @@ async fn lookup( return Ok(out); } + let udp_timeout = udp_dns_timeout(timeout); let futures = INSPECT_TYPES.iter().copied().map(|query_type| { let target = target.clone(); async move { match target { ResolverTarget::Doh { url, .. } => lookup_doh_records(&url, host, query_type).await, ResolverTarget::Udp { addr, .. } => { - lookup_udp_records(&addr, host, query_type).await + lookup_udp_records(&addr, host, query_type, udp_timeout).await } ResolverTarget::Default { .. } => unreachable!("default resolver handled earlier"), } @@ -345,6 +349,7 @@ async fn lookup_udp_records( server_addr: &str, host: &str, query_type: QueryType, + timeout: Duration, ) -> Result, FetchError> { let id = dns_query_id(); let raw = build_dns_query(id, host, query_type.dns_type)?; @@ -358,17 +363,14 @@ async fn lookup_udp_records( socket.send(&raw).await?; let mut buf = vec![0u8; 4096]; - let n = socket.recv(&mut buf).await?; + let n = match tokio::time::timeout(timeout, socket.recv(&mut buf)).await { + Ok(Ok(n)) => n, + Ok(Err(err)) => return Err(err.into()), + Err(_) => return Err("DNS lookup timed out".into()), + }; parse_dns_response(&buf[..n], id) } -fn dns_query_id() -> u16 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.subsec_nanos() as u16) - .unwrap_or_default() -} - fn build_dns_query(id: u16, host: &str, dns_type: u16) -> Result, FetchError> { let mut raw = Vec::with_capacity(512); raw.extend_from_slice(&id.to_be_bytes()); @@ -1361,6 +1363,7 @@ mod tests { url: server_url, }, Instant::now(), + None, ) .await .unwrap(); @@ -1400,6 +1403,7 @@ mod tests { url: server_url, }, Instant::now(), + None, ) .await .unwrap(); @@ -1422,6 +1426,7 @@ mod tests { doh_type: "A", dns_type: DNS_TYPE_A, }, + Duration::from_secs(1), ) .await .unwrap(); diff --git a/src/dns/mod.rs b/src/dns/mod.rs index abe190f..baf9446 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -1,3 +1,4 @@ pub mod doh; pub mod inspect; pub mod resolver; +pub(crate) mod util; diff --git a/src/dns/resolver.rs b/src/dns/resolver.rs index b5419b5..bce4279 100644 --- a/src/dns/resolver.rs +++ b/src/dns/resolver.rs @@ -1,9 +1,11 @@ use std::fmt; use std::net::{IpAddr, SocketAddr}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::Duration; use tokio::net::UdpSocket; +use crate::dns::util::{dns_query_id, udp_dns_timeout}; + const DNS_TYPE_A: u16 = 1; const DNS_TYPE_AAAA: u16 = 28; const DNS_CLASS_IN: u16 = 1; @@ -25,13 +27,18 @@ pub struct DnsRecord { pub ttl: Option, } -pub async fn lookup_udp(server_addr: &str, host: &str) -> Result, ResolverError> { +pub async fn lookup_udp( + server_addr: &str, + host: &str, + timeout: Option, +) -> Result, ResolverError> { if let Ok(ip) = host.parse::() { return Ok(vec![ip]); } - let a = lookup_udp_type(server_addr, host, DNS_TYPE_A).await; - let aaaa = lookup_udp_type(server_addr, host, DNS_TYPE_AAAA).await; + 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 mut addrs = Vec::new(); if let Ok(records) = &a { @@ -53,6 +60,7 @@ pub async fn lookup_udp_type( server_addr: &str, host: &str, dns_type: u16, + timeout: Duration, ) -> Result, ResolverError> { let id = dns_query_id(); let raw = build_dns_query(id, host, dns_type)?; @@ -74,10 +82,11 @@ pub async fn lookup_udp_type( .map_err(|err| ResolverError(err.to_string()))?; let mut buf = vec![0u8; 4096]; - let n = socket - .recv(&mut buf) - .await - .map_err(|err| ResolverError(err.to_string()))?; + let n = match tokio::time::timeout(timeout, socket.recv(&mut buf)).await { + Ok(Ok(n)) => n, + Ok(Err(err)) => return Err(ResolverError(err.to_string())), + Err(_) => return Err(ResolverError("DNS lookup timed out".to_string())), + }; parse_dns_response(&buf[..n], id) } @@ -103,13 +112,6 @@ fn dns_server_value_error(server: &str) -> ResolverError { )) } -fn dns_query_id() -> u16 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.subsec_nanos() as u16) - .unwrap_or_default() -} - fn build_dns_query(id: u16, host: &str, dns_type: u16) -> Result, ResolverError> { let mut raw = Vec::with_capacity(512); raw.extend_from_slice(&id.to_be_bytes()); @@ -298,7 +300,7 @@ mod tests { async fn lookup_udp_returns_a_and_aaaa() { let (addr, stop) = start_udp_server(DnsServerMode::Success); - let addrs = lookup_udp(&addr, "example.com").await.unwrap(); + let addrs = lookup_udp(&addr, "example.com", None).await.unwrap(); assert_eq!( addrs.iter().map(ToString::to_string).collect::>(), @@ -311,7 +313,7 @@ mod tests { async fn lookup_udp_type_returns_ttl() { let (addr, stop) = start_udp_server(DnsServerMode::Success); - let records = lookup_udp_type(&addr, "example.com", DNS_TYPE_A) + let records = lookup_udp_type(&addr, "example.com", DNS_TYPE_A, Duration::from_secs(1)) .await .unwrap(); @@ -323,7 +325,7 @@ mod tests { #[tokio::test] async fn lookup_udp_ip_literal_skips_server() { - let addrs = lookup_udp("127.0.0.1:9", "127.0.0.1").await.unwrap(); + let addrs = lookup_udp("127.0.0.1:9", "127.0.0.1", None).await.unwrap(); assert_eq!(addrs, ["127.0.0.1".parse::().unwrap()]); } @@ -332,12 +334,27 @@ mod tests { async fn lookup_udp_nxdomain_mentions_rcode() { let (addr, stop) = start_udp_server(DnsServerMode::NxDomain); - let err = lookup_udp(&addr, "missing.example").await.unwrap_err(); + let err = lookup_udp(&addr, "missing.example", None) + .await + .unwrap_err(); assert!(err.to_string().contains("NXDomain")); stop(); } + #[tokio::test] + async fn lookup_udp_type_times_out_waiting_for_response() { + let socket = StdUdpSocket::bind("127.0.0.1:0").unwrap(); + let addr = socket.local_addr().unwrap().to_string(); + + let err = lookup_udp_type(&addr, "example.com", DNS_TYPE_A, Duration::from_millis(10)) + .await + .unwrap_err(); + + assert_eq!(err.to_string(), "DNS lookup timed out"); + drop(socket); + } + #[test] fn normalize_udp_dns_server_matches_go_parser() { assert_eq!( diff --git a/src/dns/util.rs b/src/dns/util.rs new file mode 100644 index 0000000..b2b8c69 --- /dev/null +++ b/src/dns/util.rs @@ -0,0 +1,11 @@ +use std::time::Duration; + +pub(crate) const DEFAULT_UDP_DNS_TIMEOUT: Duration = Duration::from_secs(5); + +pub(crate) fn dns_query_id() -> u16 { + rand::random::() +} + +pub(crate) fn udp_dns_timeout(timeout: Option) -> Duration { + timeout.unwrap_or(DEFAULT_UDP_DNS_TIMEOUT) +} diff --git a/src/http/mod.rs b/src/http/mod.rs index 63f7e6c..da98b90 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -453,7 +453,7 @@ async fn resolve_dns_for_client_inner( } else { let server_addr = crate::dns::resolver::normalize_udp_dns_server(dns_server) .map_err(|err| FetchError::Message(err.to_string()))?; - crate::dns::resolver::lookup_udp(&server_addr, host) + crate::dns::resolver::lookup_udp(&server_addr, host, timeout) .await .map_err(|err| FetchError::Runtime(format!("lookup {host}: {err}")))? };