Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
111 changes: 106 additions & 5 deletions src/dns/doh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -63,13 +66,27 @@ pub async fn lookup_doh_type(
answer_type: u16,
timeout: Option<Duration>,
) -> Result<Vec<DnsRecord>, 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<Duration>) -> Result<reqwest::Client, DnsError> {
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<Vec<DnsRecord>, DnsError> {
let url = doh_query_url(server_url, host, dns_type);

let response = client
.get(url)
.header(ACCEPT, "application/dns-json")
Expand Down Expand Up @@ -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<F>(handler: F) -> (Url, tokio::task::JoinHandle<()>)
where
Expand Down Expand Up @@ -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()));
Expand Down Expand Up @@ -278,7 +356,30 @@ mod tests {
addrs.iter().map(ToString::to_string).collect::<Vec<_>>(),
["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::<Vec<_>>(),
["127.0.0.1", "::1"]
);
assert!(
elapsed < Duration::from_millis(400),
"lookup took {elapsed:?}, expected parallel A/AAAA queries near {delay:?}"
);
task.abort();
}

Expand Down
100 changes: 96 additions & 4 deletions src/dns/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand All @@ -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::<Vec<_>>(),
["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);
Expand Down Expand Up @@ -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<u8>, dns_type: u16, ttl: u32, data: &[u8]) {
response.extend_from_slice(&[0xc0, 0x0c]);
response.extend_from_slice(&dns_type.to_be_bytes());
Expand Down
24 changes: 20 additions & 4 deletions src/http/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,18 +158,34 @@ async fn resolve_dns_for_client(
) -> Result<Option<DnsResolution>, 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,
Expand Down
Loading