Revision control
Copy as Markdown
Other Tools
//! By-name connection modes: [`NetworkConfig::resolution`] set to
//! [`ResolutionMode::ByName`] or [`ResolutionMode::ByNameWithHttpsRr`].
//!
//! When the host should not (or cannot) be resolved client-side (e.g. a proxy
//! resolves it for us, or an inner proxy connection is being established), the
//! state machine attempts by name instead of racing IPs. `ByName` skips DNS
//! entirely; `ByNameWithHttpsRr` still fetches the origin HTTPS record for its
//! ALPN (so h3 is attempted when advertised) but never queries A/AAAA.
mod common;
use common::*;
use std::time::Instant;
use happy_eyeballs::{
ConnectionAttemptHttpVersions, DnsRecordType, Endpoint, EndpointTarget, FailureReason,
HappyEyeballs, HttpVersions, Id, NetworkConfig, Output, ResolutionMode,
};
fn by_name_config() -> NetworkConfig {
NetworkConfig {
resolution: ResolutionMode::ByName,
..NetworkConfig::default()
}
}
fn by_name_https_rr_config() -> NetworkConfig {
NetworkConfig {
resolution: ResolutionMode::ByNameWithHttpsRr,
..NetworkConfig::default()
}
}
fn out_attempt_by_name(id: Id, http_version: ConnectionAttemptHttpVersions) -> Output {
Output::AttemptConnection {
id,
endpoint: Endpoint {
target: EndpointTarget::Name {
host: HOSTNAME.to_string(),
port: PORT,
},
http_version,
ech_config: None,
},
is_ech_retry: false,
}
}
/// In `ByName` mode the machine emits no DNS query at all and immediately
/// yields a by-name connection attempt over the default H2/H1 versions.
#[test]
fn by_name_no_dns_query_and_by_name_attempt() {
let now = Instant::now();
let mut he = HappyEyeballs::new_with_network_config(HOSTNAME, PORT, by_name_config()).unwrap();
// First output is the by-name attempt, not a SendDnsQuery.
he.expect(
out_attempt_by_name(Id::from(0), ConnectionAttemptHttpVersions::H2OrH1),
now,
);
// The attempt carries no resolved address.
let attempt = out_attempt_by_name(Id::from(0), ConnectionAttemptHttpVersions::H2OrH1)
.attempt()
.unwrap();
assert_eq!(attempt.address(), None);
// The only follow-up is the connection-attempt delay timer while the
// attempt is in flight. No DNS query is ever emitted.
he.expect(out_connection_attempt_delay(), now);
}
/// A successful by-name attempt is reported as `Succeeded`.
#[test]
fn by_name_success() {
let now = Instant::now();
let mut he = HappyEyeballs::new_with_network_config(HOSTNAME, PORT, by_name_config()).unwrap();
he.expect(
out_attempt_by_name(Id::from(0), ConnectionAttemptHttpVersions::H2OrH1),
now,
);
he.input(in_connection_result_positive(Id::from(0)), now);
he.expect(Output::Succeeded, now);
}
/// When the single by-name attempt fails and there is nothing left to try, the
/// machine reports a connection failure (never a DNS-resolution failure, since
/// no DNS was attempted).
#[test]
fn by_name_failure() {
let now = Instant::now();
let mut he = HappyEyeballs::new_with_network_config(HOSTNAME, PORT, by_name_config()).unwrap();
he.expect(
out_attempt_by_name(Id::from(0), ConnectionAttemptHttpVersions::H2OrH1),
now,
);
he.input(in_connection_result_negative(Id::from(0)), now);
he.expect(Output::Failed(FailureReason::Connection), now);
}
/// The enabled HTTP versions are respected: with only HTTP/1.1 enabled the
/// by-name attempt uses H1.
#[test]
fn by_name_respects_http_versions() {
let now = Instant::now();
let config = NetworkConfig {
resolution: ResolutionMode::ByName,
http_versions: HttpVersions {
h1: true,
h2: false,
h3: false,
},
..NetworkConfig::default()
};
let mut he = HappyEyeballs::new_with_network_config(HOSTNAME, PORT, config).unwrap();
he.expect(
out_attempt_by_name(Id::from(0), ConnectionAttemptHttpVersions::H1),
now,
);
}
/// Even with HTTP/3 enabled, a plain `ByName` attempt never offers H3: without
/// an HTTPS record proving H3 support the machine falls back to H2/H1,
/// mirroring how it treats an IP-literal host.
#[test]
fn by_name_does_not_offer_h3() {
let now = Instant::now();
let mut he = HappyEyeballs::new_with_network_config(HOSTNAME, PORT, by_name_config()).unwrap();
let attempt = he.process_output(now).unwrap().attempt().unwrap();
assert_eq!(attempt.http_version, ConnectionAttemptHttpVersions::H2OrH1);
}
/// `ByName` mode attempts a domain alt-svc target by name, over its advertised
/// protocol: an h3 alt-svc is raced over h3 (with no SendDnsQuery for it),
/// ahead of the by-name origin H2/H1 fallback.
#[test]
fn by_name_attempts_alt_svc_by_name_over_h3() {
use happy_eyeballs::{AltSvc, HttpVersion};
let mut now = Instant::now();
let config = NetworkConfig {
resolution: ResolutionMode::ByName,
alt_svc: vec![AltSvc {
host: Some("alt.example.com".to_string()),
port: None,
http_version: HttpVersion::H3,
}],
..NetworkConfig::default()
};
let mut he = HappyEyeballs::new_with_network_config(HOSTNAME, PORT, config).unwrap();
// First attempt: the alt-svc target, by name, over h3. It is preferred over
// the by-name origin H2/H1 fallback, and no DNS query is emitted for it.
he.expect(
Output::AttemptConnection {
id: Id::from(0),
endpoint: Endpoint {
target: EndpointTarget::Name {
host: "alt.example.com".to_string(),
port: PORT,
},
http_version: ConnectionAttemptHttpVersions::H3,
ech_config: None,
},
is_ech_retry: false,
},
now,
);
he.expect(out_connection_attempt_delay(), now);
// After the connection attempt delay fires, fall back to the non-alt-svc
// origin, still by name, over H2/H1. Nothing remains to try after that.
he.expect_connection_attempts(
[out_attempt_by_name(
Id::from(1),
ConnectionAttemptHttpVersions::H2OrH1,
)],
&mut now,
);
}
/// `ByNameWithHttpsRr` sends exactly one DNS query, the origin HTTPS record, and
/// never an A or AAAA query. Before the answer arrives it emits nothing else:
/// it waits for the HTTPS answer rather than racing addresses.
#[test]
fn https_rr_only_sends_https_query() {
let now = Instant::now();
let mut he =
HappyEyeballs::new_with_network_config(HOSTNAME, PORT, by_name_https_rr_config()).unwrap();
// The one and only DNS query: HTTPS for the origin. No AAAA/A burst follows.
he.expect(
out_send_dns(Id::from(0), HOSTNAME, DnsRecordType::Https),
now,
);
he.expect_idle(now);
// Feeding the HTTPS answer unblocks the by-name attempts; still no A/AAAA.
he.input(in_dns_https_positive(Id::from(0)), now);
let attempt = he.process_output(now).unwrap().attempt().unwrap();
assert!(matches!(attempt.target, EndpointTarget::Name { .. }));
// No further DNS query is ever produced.
he.expect(out_connection_attempt_delay(), now);
}
/// An HTTPS record advertising h3 yields a by-name h3 attempt to the origin at
/// the origin port. The ALPN is honored (h3 first), and the endpoint carries no
/// resolved address.
#[test]
fn https_rr_h3_record_yields_by_name_h3() {
let now = Instant::now();
let mut he =
HappyEyeballs::new_with_network_config(HOSTNAME, PORT, by_name_https_rr_config()).unwrap();
he.expect(
out_send_dns(Id::from(0), HOSTNAME, DnsRecordType::Https),
now,
);
// The record advertises h3 (and h2); the first by-name attempt is over h3.
he.input(in_dns_https_positive(Id::from(0)), now);
he.expect(
out_attempt_by_name(Id::from(1), ConnectionAttemptHttpVersions::H3),
now,
);
let attempt = out_attempt_by_name(Id::from(1), ConnectionAttemptHttpVersions::H3)
.attempt()
.unwrap();
assert_eq!(attempt.address(), None);
}
/// A negative HTTPS answer (as on failure or timeout) falls back to the by-name
/// origin over the enabled H2/H1 versions.
#[test]
fn https_rr_negative_falls_back_to_by_name_h2_h1() {
let now = Instant::now();
let mut he =
HappyEyeballs::new_with_network_config(HOSTNAME, PORT, by_name_https_rr_config()).unwrap();
he.expect(
out_send_dns(Id::from(0), HOSTNAME, DnsRecordType::Https),
now,
);
he.input(in_dns_https_negative(Id::from(0)), now);
// No HTTPS endpoints, so the by-name origin H2/H1 fallback is used.
he.expect(
out_attempt_by_name(Id::from(1), ConnectionAttemptHttpVersions::H2OrH1),
now,
);
}
/// An empty (NODATA) HTTPS answer, like a negative one, falls back to the
/// by-name origin over H2/H1.
#[test]
fn https_rr_empty_falls_back_to_by_name_h2_h1() {
let now = Instant::now();
let mut he =
HappyEyeballs::new_with_network_config(HOSTNAME, PORT, by_name_https_rr_config()).unwrap();
he.expect(
out_send_dns(Id::from(0), HOSTNAME, DnsRecordType::Https),
now,
);
he.input(in_dns_https_positive_no_alpn(Id::from(0)), now);
// A record with no usable ALPN yields no HTTPS endpoints, so the by-name
// origin H2/H1 fallback is used.
he.expect(
out_attempt_by_name(Id::from(1), ConnectionAttemptHttpVersions::H2OrH1),
now,
);
}
/// Regression: with the default [`ResolutionMode::ByIp`], a domain host still
/// opens the normal DNS phase, emitting HTTPS/AAAA/A queries before any
/// connection attempt.
#[test]
fn default_still_resolves() {
let now = Instant::now();
let mut he = HappyEyeballs::new(HOSTNAME, PORT).unwrap();
expect_initial_dns_queries(&mut he, now);
// Feeding a normal DNS flow yields an address-keyed attempt, exactly as
// before this feature existed.
he.input(in_dns_https_positive(Id::from(0)), now);
he.expect(out_resolution_delay(), now);
he.input(in_dns_aaaa_positive(Id::from(1)), now);
let attempt = he.process_output(now).unwrap().attempt().unwrap();
assert!(matches!(attempt.target, EndpointTarget::Address(_)));
assert!(attempt.address().is_some());
}
/// A record's TargetName is a DNS name, but a by-name attempt needs a connect
/// host: the root label's trailing dot is dropped (it is invalid as SNI and is
/// rejected by proxies), and a ServiceMode target of "." denotes the owner name,
/// i.e. the origin.
#[test]
fn https_rr_by_name_host_is_a_connect_name() {
use happy_eyeballs::{DnsResult, HttpVersion, Input};
for (target, expected) in [(SVC1, "svc1.example.com"), (".", HOSTNAME)] {
let now = Instant::now();
let mut he =
HappyEyeballs::new_with_network_config(HOSTNAME, PORT, by_name_https_rr_config())
.unwrap();
he.expect(
out_send_dns(Id::from(0), HOSTNAME, DnsRecordType::Https),
now,
);
he.input(
Input::DnsResult {
id: Id::from(0),
result: DnsResult::Https(Ok(vec![service_info(1, target, &[HttpVersion::H3])])),
stale: false,
},
now,
);
let attempt = he.process_output(now).unwrap().attempt().unwrap();
assert_eq!(
attempt.target,
EndpointTarget::Name {
host: expected.to_string(),
port: PORT,
},
"target name {target:?} should connect to {expected:?}"
);
}
}
/// `ByName` performs no DNS at all, so a failure can never be a resolution
/// failure. With no usable HTTP version there is nothing to attempt, and the
/// machine reports a connection failure rather than a DNS one.
#[test]
fn by_name_failure_is_never_dns_resolution() {
let now = Instant::now();
let config = NetworkConfig {
resolution: ResolutionMode::ByName,
http_versions: HttpVersions {
h1: false,
h2: false,
h3: true,
},
..NetworkConfig::default()
};
let mut he = HappyEyeballs::new_with_network_config(HOSTNAME, PORT, config).unwrap();
he.expect(Output::Failed(FailureReason::Connection), now);
}
/// An origin may legitimately be given fully qualified ("example.com."), but a
/// connect name must not carry the root label's trailing dot. That holds for the
/// by-name origin, for an alt-svc entry that omits a host (which defaults to the
/// origin), and for an HTTPS record whose target is "." (the owner name). The
/// DNS query itself still uses the name as given.
#[test]
fn by_name_origin_drops_trailing_root_label() {
use happy_eyeballs::{AltSvc, DnsResult, HttpVersion, Input};
const FQDN: &str = "example.com.";
let expected = EndpointTarget::Name {
host: HOSTNAME.to_string(),
port: PORT,
};
let now = Instant::now();
// The plain by-name origin.
let mut he = HappyEyeballs::new_with_network_config(FQDN, PORT, by_name_config()).unwrap();
let attempt = he.process_output(now).unwrap().attempt().unwrap();
assert_eq!(attempt.target, expected, "by-name origin");
// An alt-svc entry without a host of its own defaults to the origin.
let config = NetworkConfig {
resolution: ResolutionMode::ByName,
alt_svc: vec![AltSvc {
host: None,
port: None,
http_version: HttpVersion::H3,
}],
..NetworkConfig::default()
};
let mut he = HappyEyeballs::new_with_network_config(FQDN, PORT, config).unwrap();
let attempt = he.process_output(now).unwrap().attempt().unwrap();
assert_eq!(attempt.target, expected, "alt-svc defaulting to the origin");
// An HTTPS record whose target name is the owner name (".").
let mut he =
HappyEyeballs::new_with_network_config(FQDN, PORT, by_name_https_rr_config()).unwrap();
he.expect(out_send_dns(Id::from(0), FQDN, DnsRecordType::Https), now);
he.input(
Input::DnsResult {
id: Id::from(0),
result: DnsResult::Https(Ok(vec![service_info(1, ".", &[HttpVersion::H3])])),
stale: false,
},
now,
);
let attempt = he.process_output(now).unwrap().attempt().unwrap();
assert_eq!(
attempt.target, expected,
"HTTPS record targeting the owner name"
);
}