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
10 changes: 6 additions & 4 deletions docs/advanced-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,12 @@ platform resolver, matching normal address lookup. HTTPS-record discovery and no
run in parallel. `fetch` starts the TCP/TLS path as soon as normal DNS produces
a usable address, and a usable `h3` candidate that is discovered before TCP/TLS
wins races QUIC setup against it. The request is sent once on the winning
transport. If HTTPS-record discovery is too slow, fails, is unsupported by the
OS resolver, or returns no usable `h3` record, HTTPS uses the normal ALPN path
and offers `h2` then `http/1.1`. Proxy and Unix socket requests also use the
normal ALPN path.
transport. With system, UDP, TCP, or plaintext HTTP DNS, a slow or failed
HTTPS-record lookup uses the normal ALPN path. With certificate-verified DoH,
DoT, or DoQ, a transport, server, or malformed-response failure stops the
connection to prevent protocol downgrade. An authenticated NODATA or NXDOMAIN
result can use the normal ALPN path. Proxy and Unix socket requests also use
the normal ALPN path.

`fetch` also remembers recent HTTP/3 alternatives learned from HTTPS/SVCB
records and `Alt-Svc: h3=...` response headers in a bounded per-origin cache
Expand Down
14 changes: 9 additions & 5 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,8 +543,10 @@ fetch --min-tls 1.2 --max-tls 1.2 example.com
Encrypted Client Hello mode. Values: `auto`, `on`, `off`. Default: `off`.

- **`auto`** — Use ECH if the server advertises it in DNS HTTPS/SVCB records.
Falls back to GREASE ECH when no real config is found. If the server
rejects the offer, the connection proceeds gracefully.
Falls back to GREASE ECH when an authenticated lookup completes without a
real config. An authenticated DNS lookup failure stops the connection to
prevent downgrade. If the server rejects the offer, the connection proceeds
gracefully.
- **`on`** — Require ECH. `fetch` reports an error if the server does not
advertise ECH in DNS or rejects the offer. This mode cannot be combined with
explicit HTTP/3. Automatic protocol selection uses TCP.
Expand Down Expand Up @@ -617,9 +619,11 @@ opportunistic and does not delay the normal address lookup or TCP/TLS setup:
`fetch` starts
TCP/TLS as soon as normal DNS produces a usable address, while a usable `h3`
record discovered before TCP/TLS wins races QUIC setup against it. The request
is sent once on the winning transport. If HTTPS-record discovery is too slow,
fails, is unsupported by the OS resolver, or returns no usable `h3` record,
HTTPS offers `h2` then `http/1.1` through ALPN. Proxy and Unix socket requests
is sent once on the winning transport. System, UDP, TCP, and plaintext HTTP
DNS can fall back after an HTTPS-record lookup failure. A transport, server,
or malformed-response failure from certificate-verified DoH, DoT, or DoQ
stops the connection to prevent protocol downgrade. Authenticated NODATA and
NXDOMAIN results can use the normal ALPN path. Proxy and Unix socket requests
also use the normal ALPN path.

`--http 1`, `--http 2`, and `--http 3` force that protocol instead of setting
Expand Down
10 changes: 6 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,10 +400,12 @@ DoT, DoQ, or DoH resolver. Without `dns-server`, it uses the platform resolver,
matching normal address lookup. Discovery runs in parallel with normal A/AAAA
lookup and TCP/TLS setup. `fetch` starts TCP/TLS as soon as normal DNS produces a usable
address, while a usable `h3` record discovered before TCP/TLS wins races QUIC
setup against it. The request is sent once on the winning transport. If
HTTPS-record discovery is too slow, fails, is unsupported by the OS resolver,
or returns no usable `h3` record, HTTPS offers `h2` then `http/1.1` through
ALPN. Proxy and Unix socket requests also use the normal ALPN path.
setup against it. The request is sent once on the winning transport. System,
UDP, TCP, and plaintext HTTP DNS can fall back after an HTTPS-record lookup
failure. A transport, server, or malformed-response failure from
certificate-verified DoH, DoT, or DoQ stops the connection to prevent protocol
downgrade. Authenticated NODATA and NXDOMAIN results can use the normal ALPN
path. Proxy and Unix socket requests also use the normal ALPN path.

Setting this option to `1`, `2`, or `3` forces that protocol. It does not set a
version cap. Set `http = 1` or `http = 2` to opt out of automatic HTTP/3.
Expand Down
8 changes: 5 additions & 3 deletions docs/ech.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ fetch --ech off https://example.com
## Modes

- **`auto`** — Use ECH if the server advertises it in DNS. Falls back to
GREASE ECH when no real config is found. If the server rejects the offer,
the connection proceeds (outer ClientHello fallback). This is the
recommended mode for general use.
GREASE ECH when an authenticated lookup completes without a real config.
An authenticated DNS transport, server, or response failure stops the
connection to prevent downgrade. If the server rejects the ECH offer, the
connection proceeds (outer ClientHello fallback). This is the recommended
mode for general use.

- **`on`** — Require ECH. Errors if the server does not advertise ECH in DNS,
and fails if the server rejects the offer. This mode cannot be used with
Expand Down
103 changes: 87 additions & 16 deletions src/dns/custom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,28 @@ pub(crate) enum ParsedDnsServer {
Doh(Url),
}

impl ParsedDnsServer {
/// Returns whether DNS responses have transport authentication.
///
/// DoT and DoQ always verify the configured resolver identity. HTTPS DoH
/// is authenticated unless certificate verification was disabled by the
/// caller. Plain HTTP, UDP, and TCP do not authenticate responses.
pub(crate) fn is_authenticated(&self, verify_doh_certificate: bool) -> bool {
match self {
Self::Tls { .. } | Self::Quic { .. } => true,
Self::Doh(url) => url.scheme() == "https" && verify_doh_certificate,
Self::Udp(_) | Self::Tcp(_) => false,
}
}
}

pub(crate) fn dns_server_is_authenticated(
value: &str,
verify_doh_certificate: bool,
) -> Result<bool, FetchError> {
Ok(parse_dns_server(value)?.is_authenticated(verify_doh_certificate))
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DnsRecordData {
Wire(Vec<u8>),
Expand Down Expand Up @@ -219,10 +241,20 @@ pub(crate) async fn query_type(
ParsedDnsServer::Doh(url) => {
let client = crate::dns::doh::client_with_budget_and_tls_config(budget, doh_tls_config)
.map_err(|err| FetchError::Message(err.to_string()))?;
let answers =
crate::dns::doh::lookup_doh_records_with_client(&client, url, host, dns_type_name)
.await
.map_err(|err| FetchError::Runtime(format!("lookup {host}: {err}")))?;
let answers = match crate::dns::doh::lookup_doh_records_with_client(
&client,
url,
host,
dns_type_name,
)
.await
{
Ok(answers) => answers,
Err(err) if err.is_nxdomain() => return Ok(Vec::new()),
Err(err) => {
return Err(FetchError::Runtime(format!("lookup {host}: {err}")));
}
};
return Ok(answers
.into_iter()
.map(|answer| DnsQueryRecord {
Expand All @@ -234,18 +266,18 @@ pub(crate) async fn query_type(
}
};

wire_records
.map(|records| {
records
.into_iter()
.map(|record| DnsQueryRecord {
typ: record.typ,
ttl: Some(record.ttl),
data: DnsRecordData::Wire(record.data),
})
.collect()
})
.map_err(|err| FetchError::Runtime(format!("lookup {host}: {err}")))
match wire_records {
Ok(records) => Ok(records
.into_iter()
.map(|record| DnsQueryRecord {
typ: record.typ,
ttl: Some(record.ttl),
data: DnsRecordData::Wire(record.data),
})
.collect()),
Err(err) if err.is_nxdomain() => Ok(Vec::new()),
Err(err) => Err(FetchError::Runtime(format!("lookup {host}: {err}"))),
}
}

pub(crate) async fn lookup_ips(
Expand Down Expand Up @@ -304,6 +336,45 @@ mod tests {

use super::*;

#[test]
fn classifies_authenticated_dns_transports() {
assert!(
!parse_dns_server("udp://127.0.0.1")
.unwrap()
.is_authenticated(true)
);
assert!(
!parse_dns_server("tcp://127.0.0.1")
.unwrap()
.is_authenticated(true)
);
assert!(
!parse_dns_server("http://resolver.example/dns-query")
.unwrap()
.is_authenticated(true)
);
assert!(
!parse_dns_server("https://resolver.example/dns-query")
.unwrap()
.is_authenticated(false)
);
assert!(
parse_dns_server("https://resolver.example/dns-query")
.unwrap()
.is_authenticated(true)
);
assert!(
parse_dns_server("tls://resolver.example")
.unwrap()
.is_authenticated(true)
);
assert!(
parse_dns_server("doq://resolver.example")
.unwrap()
.is_authenticated(true)
);
}

#[test]
fn socket_addrs_use_zero_port_for_transport_override() {
let addrs = socket_addrs_for_override(&["127.0.0.1".parse().unwrap()]);
Expand Down
6 changes: 6 additions & 0 deletions src/dns/doh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ impl fmt::Display for DnsError {

impl std::error::Error for DnsError {}

impl DnsError {
pub(crate) fn is_nxdomain(&self) -> bool {
self.0 == "no such host: NXDomain"
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DnsRecord {
pub ip: IpAddr,
Expand Down
6 changes: 6 additions & 0 deletions src/dns/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ impl fmt::Display for ResolverError {

impl std::error::Error for ResolverError {}

impl ResolverError {
pub(crate) fn is_nxdomain(&self) -> bool {
self.0 == "no such host: NXDomain"
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DnsRecord {
pub ip: IpAddr,
Expand Down
74 changes: 64 additions & 10 deletions src/dns/svcb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ pub(crate) async fn lookup_https_records_with_doh_tls_config(
doh_tls_config,
)
.await?;
Ok(svcb_records_from_query(records))
svcb_records_from_query(records)
}
HttpsRecordResolver::System => {
system::lookup_https_records(host, TimeoutBudget::new(timeout)).await
Expand All @@ -271,19 +271,22 @@ pub(crate) async fn lookup_https_records_with_doh_tls_config(

pub(super) fn svcb_records_from_query(
records: Vec<crate::dns::custom::DnsQueryRecord>,
) -> Vec<SvcbRecord> {
) -> Result<Vec<SvcbRecord>, FetchError> {
records
.into_iter()
.filter(|record| record.typ == DNS_TYPE_HTTPS)
.filter_map(|record| {
.map(|record| {
let raw = match record.data {
DnsRecordData::Wire(raw) => Some(raw),
DnsRecordData::Text(text) => parse_generic_rdata(&text),
}?;
parse_rdata(&raw).map(|mut parsed| {
parsed.ttl = record.ttl;
parsed
})
DnsRecordData::Wire(raw) => raw,
DnsRecordData::Text(text) => parse_generic_rdata(&text).ok_or_else(|| {
FetchError::Runtime("malformed HTTPS DNS record data".to_string())
})?,
};
let mut parsed = parse_rdata(&raw).ok_or_else(|| {
FetchError::Runtime("malformed HTTPS DNS record data".to_string())
})?;
parsed.ttl = record.ttl;
Ok(parsed)
})
.collect()
}
Expand Down Expand Up @@ -494,6 +497,17 @@ mod tests {
assert_eq!(parse_rdata(&bad_port), None);
}

#[test]
fn malformed_https_record_rejects_the_lookup() {
let result = svcb_records_from_query(vec![crate::dns::custom::DnsQueryRecord {
typ: DNS_TYPE_HTTPS,
ttl: Some(60),
data: DnsRecordData::Wire(vec![0, 1]),
}]);

assert!(result.unwrap_err().to_string().contains("malformed HTTPS"));
}

#[tokio::test]
async fn custom_tcp_lookup_returns_https_records() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
Expand Down Expand Up @@ -552,6 +566,46 @@ mod tests {
handle.join().unwrap();
}

#[tokio::test]
async fn custom_tcp_nxdomain_is_a_completed_empty_lookup() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut len = [0u8; 2];
stream.read_exact(&mut len).unwrap();
let mut query = vec![0u8; usize::from(u16::from_be_bytes(len))];
stream.read_exact(&mut query).unwrap();
let question_end = query
.iter()
.enumerate()
.skip(12)
.find_map(|(index, byte)| (*byte == 0).then_some(index + 5))
.unwrap();
let mut response = Vec::new();
response.extend_from_slice(&query[..2]);
response.extend_from_slice(&0x8183u16.to_be_bytes());
response.extend_from_slice(&1u16.to_be_bytes());
response.extend_from_slice(&0u32.to_be_bytes());
response.extend_from_slice(&query[12..question_end]);
stream
.write_all(&(response.len() as u16).to_be_bytes())
.unwrap();
stream.write_all(&response).unwrap();
});

let records = lookup_https_records(
HttpsRecordResolver::Custom(&format!("tcp://{addr}")),
"missing.example",
Some(Duration::from_secs(1)),
)
.await
.unwrap();

assert!(records.is_empty());
handle.join().unwrap();
}

#[tokio::test]
async fn system_lookup_skips_ip_literal_hosts() {
let records = lookup_https_records(
Expand Down
2 changes: 1 addition & 1 deletion src/dns/svcb/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub(super) async fn lookup_https_records(
None,
)
.await?;
Ok(super::svcb_records_from_query(records))
super::svcb_records_from_query(records)
}

#[cfg(not(all(unix, not(target_os = "macos"))))]
Expand Down
Loading
Loading