diff --git a/.cargo/config.toml.in b/.cargo/config.toml.in index 1bd188807a4e8..3d2d16f1ef43d 100644 --- a/.cargo/config.toml.in +++ b/.cargo/config.toml.in @@ -79,9 +79,9 @@ git = "https://github.com/mozilla/cubeb-pulse-rs" rev = "3cf4a1af09a1748d0e184297a17132bdcc608874" replace-with = "vendored-sources" -[source."git+https://github.com/mozilla/happy-eyeballs?tag=v0.6.0"] +[source."git+https://github.com/mozilla/happy-eyeballs?tag=v0.7.0"] git = "https://github.com/mozilla/happy-eyeballs" -tag = "v0.6.0" +tag = "v0.7.0" replace-with = "vendored-sources" [source."git+https://github.com/mozilla/memtest?rev=ad681ba425beb0aeba95f03e671432b4be932174"] diff --git a/Cargo.lock b/Cargo.lock index 53db108595321..6f8133453ec0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3220,8 +3220,8 @@ dependencies = [ [[package]] name = "happy-eyeballs" -version = "0.6.0" -source = "git+https://github.com/mozilla/happy-eyeballs?tag=v0.6.0#0807628db2bb90ced2c05e1f236473ee92719d22" +version = "0.7.0" +source = "git+https://github.com/mozilla/happy-eyeballs?tag=v0.7.0#93a4ed6a5c996d9aa20451f0bd8703c0e0c39c87" dependencies = [ "log", "thiserror 2.0.12", diff --git a/netwerk/dns/HTTPSSVC.cpp b/netwerk/dns/HTTPSSVC.cpp index 4260cab820ef0..ed6ceb32fbbce 100644 --- a/netwerk/dns/HTTPSSVC.cpp +++ b/netwerk/dns/HTTPSSVC.cpp @@ -286,6 +286,11 @@ NS_IMETHODIMP SVCBRecord::GetHasIPHintAddress(bool* aHasIPHintAddress) { return NS_OK; } +// Legacy HTTPS-RR / CNAME consistency check for the non-Happy-Eyeballs-v3 +// code path. When network.http.happy_eyeballs_enabled is true this check is +// handled unconditionally inside the happy-eyeballs crate; this function (and +// the network.dns.https_rr.check_record_with_cname pref) only take effect on +// the legacy path. static bool CheckRecordIsUsableWithCname(const SVCB& aRecord, const nsACString& aCname) { if (StaticPrefs::network_dns_https_rr_check_record_with_cname() && diff --git a/netwerk/protocol/http/HappyEyeballsConnectionAttempt.cpp b/netwerk/protocol/http/HappyEyeballsConnectionAttempt.cpp index 92f14daceb0d0..ac4e87de9c73b 100644 --- a/netwerk/protocol/http/HappyEyeballsConnectionAttempt.cpp +++ b/netwerk/protocol/http/HappyEyeballsConnectionAttempt.cpp @@ -419,6 +419,10 @@ HappyEyeballsConnectionAttempt::SetupDnsFlags( break; } + // The HTTPS-RR / CNAME consistency check always runs in Happy Eyeballs v3 + // and needs the canonical name from the origin's address resolution. + dnsFlags |= nsIDNSService::RESOLVE_CANONICAL_NAME; + // Deal with IP hints later /*if (ent->mConnInfo->HasIPHintAddress()) { nsresult rv; @@ -1513,8 +1517,9 @@ nsresult HappyEyeballsConnectionAttempt::OnARecord(nsIDNSRecord* aRecord, nsresult rv; if (NS_FAILED(status) || !addrRecord) { nsTArray emptyArray; - rv = - happy_eyeballs_process_dns_response_a(mHappyEyeballs, aId, &emptyArray); + nsAutoCString cname; + rv = happy_eyeballs_process_dns_response_a(mHappyEyeballs, aId, &emptyArray, + &cname); if (NS_FAILED(rv)) { return rv; } @@ -1524,6 +1529,9 @@ nsresult HappyEyeballsConnectionAttempt::OnARecord(nsIDNSRecord* aRecord, nsTArray addresses; addrRecord->GetAddresses(addresses); + nsAutoCString cname; + (void)addrRecord->GetCanonicalName(cname); + // Filter to only IPv4 addresses nsTArray ipv4Addresses; for (const auto& addr : addresses) { @@ -1534,7 +1542,7 @@ nsresult HappyEyeballsConnectionAttempt::OnARecord(nsIDNSRecord* aRecord, } rv = happy_eyeballs_process_dns_response_a(mHappyEyeballs, aId, - &ipv4Addresses); + &ipv4Addresses, &cname); if (NS_FAILED(rv)) { return rv; } @@ -1561,8 +1569,9 @@ nsresult HappyEyeballsConnectionAttempt::OnAAAARecord(nsIDNSRecord* aRecord, nsresult rv; if (NS_FAILED(status) || !addrRecord) { nsTArray emptyArray; + nsAutoCString cname; rv = happy_eyeballs_process_dns_response_aaaa(mHappyEyeballs, aId, - &emptyArray); + &emptyArray, &cname); if (NS_FAILED(rv)) { return rv; } @@ -1572,6 +1581,9 @@ nsresult HappyEyeballsConnectionAttempt::OnAAAARecord(nsIDNSRecord* aRecord, nsTArray addresses; addrRecord->GetAddresses(addresses); + nsAutoCString cname; + (void)addrRecord->GetCanonicalName(cname); + // Filter to only IPv6 addresses nsTArray ipv6Addresses; for (const auto& addr : addresses) { @@ -1582,7 +1594,7 @@ nsresult HappyEyeballsConnectionAttempt::OnAAAARecord(nsIDNSRecord* aRecord, } rv = happy_eyeballs_process_dns_response_aaaa(mHappyEyeballs, aId, - &ipv6Addresses); + &ipv6Addresses, &cname); if (NS_FAILED(rv)) { return rv; } diff --git a/netwerk/protocol/http/happy_eyeballs_glue/Cargo.toml b/netwerk/protocol/http/happy_eyeballs_glue/Cargo.toml index 8daf83cc991d0..6b59b3c6bb4f8 100644 --- a/netwerk/protocol/http/happy_eyeballs_glue/Cargo.toml +++ b/netwerk/protocol/http/happy_eyeballs_glue/Cargo.toml @@ -9,7 +9,7 @@ description = "FFI glue layer between happy-eyeballs Rust crate and Firefox's C+ [dependencies] firefox-on-glean = { path = "../../../../toolkit/components/glean/api" } gecko-profiler = { path = "../../../../tools/profiler/rust-api" } -happy-eyeballs = { tag = "v0.6.0", git = "https://github.com/mozilla/happy-eyeballs" } +happy-eyeballs = { tag = "v0.7.0", git = "https://github.com/mozilla/happy-eyeballs" } log = "0.4" nserror = { path = "../../../../xpcom/rust/nserror" } serde = { version = "1.0", features = ["derive"] } diff --git a/netwerk/protocol/http/happy_eyeballs_glue/src/lib.rs b/netwerk/protocol/http/happy_eyeballs_glue/src/lib.rs index 25f7c52c3f4c8..7684f01d5e234 100644 --- a/netwerk/protocol/http/happy_eyeballs_glue/src/lib.rs +++ b/netwerk/protocol/http/happy_eyeballs_glue/src/lib.rs @@ -114,6 +114,7 @@ pub unsafe extern "C" fn happy_eyeballs_process_dns_response_a( he: *mut HappyEyeballs, id: u64, addrs: *const ThinVec, + cname: *const nsACString, ) -> nsresult { let Some(he) = (unsafe { he.as_mut() }) else { debug_assert!(false, "unexpected null he pointer"); @@ -125,7 +126,7 @@ pub unsafe extern "C" fn happy_eyeballs_process_dns_response_a( return NS_ERROR_INVALID_ARG; }; - he.process_dns_response_a(id, addrs) + he.process_dns_response_a(id, addrs, canonical_name(cname)) } #[no_mangle] @@ -133,6 +134,7 @@ pub unsafe extern "C" fn happy_eyeballs_process_dns_response_aaaa( he: *mut HappyEyeballs, id: u64, addrs: *const ThinVec, + cname: *const nsACString, ) -> nsresult { let Some(he) = (unsafe { he.as_mut() }) else { debug_assert!(false, "unexpected null he pointer"); @@ -144,7 +146,19 @@ pub unsafe extern "C" fn happy_eyeballs_process_dns_response_aaaa( return NS_ERROR_INVALID_ARG; }; - he.process_dns_response_aaaa(id, addrs) + he.process_dns_response_aaaa(id, addrs, canonical_name(cname)) +} + +/// Converts an optional FFI canonical-name string into a [`TargetName`]. +/// +/// A null or empty string yields `None`, matching the state machine's "empty +/// cname => no filtering" behavior. +fn canonical_name(cname: *const nsACString) -> Option { + let cname = unsafe { cname.as_ref() }?; + if cname.is_empty() { + return None; + } + Some(happy_eyeballs::TargetName::from(cname.to_utf8().as_ref())) } #[no_mangle] @@ -219,7 +233,12 @@ pub struct HappyEyeballs { } impl HappyEyeballs { - fn process_dns_response_a(&mut self, id: u64, net_addrs: &ThinVec) -> nsresult { + fn process_dns_response_a( + &mut self, + id: u64, + net_addrs: &ThinVec, + cname: Option, + ) -> nsresult { let id: happy_eyeballs::Id = id.into(); let mut addrs = Vec::with_capacity(net_addrs.len()); for na in net_addrs.iter() { @@ -237,14 +256,19 @@ impl HappyEyeballs { self.profiler.dns_response(id, &addrs); self.metrics.dns_response(id); - let result = happy_eyeballs::DnsResult::A(Ok(addrs)); + let result = happy_eyeballs::DnsResult::A(Ok(addrs), cname); let input = happy_eyeballs::Input::DnsResult { id, result }; self.inner.process_input(input, Instant::now()); NS_OK } - fn process_dns_response_aaaa(&mut self, id: u64, net_addrs: &ThinVec) -> nsresult { + fn process_dns_response_aaaa( + &mut self, + id: u64, + net_addrs: &ThinVec, + cname: Option, + ) -> nsresult { let id: happy_eyeballs::Id = id.into(); let mut addrs = Vec::with_capacity(net_addrs.len()); for na in net_addrs.iter() { @@ -263,7 +287,7 @@ impl HappyEyeballs { self.profiler.dns_response(id, &addrs); self.metrics.dns_response(id); - let result = happy_eyeballs::DnsResult::Aaaa(Ok(addrs)); + let result = happy_eyeballs::DnsResult::Aaaa(Ok(addrs), cname); let input = happy_eyeballs::Input::DnsResult { id, result }; self.inner.process_input(input, Instant::now()); diff --git a/netwerk/test/unit/test_trr_https_rr_with_cname.js b/netwerk/test/unit/test_trr_https_rr_with_cname.js index 13330918a4dc2..387754d62b4ac 100644 --- a/netwerk/test/unit/test_trr_https_rr_with_cname.js +++ b/netwerk/test/unit/test_trr_https_rr_with_cname.js @@ -42,14 +42,10 @@ add_setup(async function setup() { Services.prefs.setBoolPref("network.dns.port_prefixed_qname_https_rr", false); - // Happy Eyeballs does not support check CNAME for now. - Services.prefs.setBoolPref("network.http.happy_eyeballs_enabled", false); - trr_test_setup(); registerCleanupFunction(async () => { trr_clear_prefs(); Services.prefs.clearUserPref("network.dns.port_prefixed_qname_https_rr"); - Services.prefs.clearUserPref("network.http.happy_eyeballs_enabled"); }); h2Port = Services.env.get("MOZHTTP2_PORT"); @@ -180,7 +176,14 @@ async function do_test_https_rr_records( // Test the case that the pref is off and the cname is not the same as the // targetName. The expected protocol version being "h3" means that the last // svcb record is used. +// +// check_record_with_cname only takes effect on the legacy (non-Happy-Eyeballs-v3) +// path; HE-v3 performs the CNAME check unconditionally, so the pref=off case +// only exists on the legacy path. Pin this test there. It can be removed once +// the non-HE-v3 path is gone. The pref is restored at the end of the task so +// the following tests still exercise HE-v3. add_task(async function test_https_rr_with_unmatched_cname() { + Services.prefs.setBoolPref("network.http.happy_eyeballs_enabled", false); Services.prefs.setBoolPref( "network.dns.https_rr.check_record_with_cname", false @@ -194,6 +197,7 @@ add_task(async function test_https_rr_with_unmatched_cname() { "test.cname1.com", "h3" ); + Services.prefs.clearUserPref("network.http.happy_eyeballs_enabled"); }); // Test the case that the pref is on and the cname is not the same as the @@ -232,9 +236,18 @@ add_task(async function test_https_rr_with_matched_cname() { ); }); -// Test the case that the pref is on and both records are failed to connect. -// We can only fallback to "h2" when another pref is on. +// Test the case that the pref is on and both HTTPS records fail to connect. +// We can only fall back to plain "h2" when network.dns.echconfig.fallback_to_origin_when_all_failed is on. +// +// This test covers ECH failure → origin fallback (not a CNAME mismatch). +// With Happy Eyeballs v3 the origin fallback after ECH failure is not implemented, +// so the test is pinned to the legacy (non-HE-v3) code path where +// echconfig.fallback_to_origin_when_all_failed still applies. add_task(async function test_https_rr_with_matched_cname_1() { + Services.prefs.setBoolPref("network.http.happy_eyeballs_enabled", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("network.http.happy_eyeballs_enabled"); + }); Services.prefs.setBoolPref( "network.dns.echconfig.fallback_to_origin_when_all_failed", true diff --git a/third_party/rust/happy-eyeballs/.cargo-checksum.json b/third_party/rust/happy-eyeballs/.cargo-checksum.json index 2bfc5efde73f5..d1ceb7d8d2af3 100644 --- a/third_party/rust/happy-eyeballs/.cargo-checksum.json +++ b/third_party/rust/happy-eyeballs/.cargo-checksum.json @@ -1 +1 @@ -{"files":{".codecov.yml":"81d2e8284c4c496f2d251510fe631509a89cfdf63a851a1b650e00de34eb7131",".github/CODEOWNERS":"fe246d8cd7003a28b9b250319c196db2051adaa15f0390a4a5e8d8a5315b0a51",".github/dependabot.yml":"91c1465d580d7ce968d40ae09ddc789a83bc6b5a8a622679071c63ad748bf207",".github/workflows/claude.yml":"47f6e2f725861de913a147e8d80bed28f51a2376798dd5aa97d9331a23089085",".github/workflows/mutants.yml":"c8f3dcafa9b560f9d87ff22f1923d6fc2fd340eb0863244113d2cd4c2891ad13",".github/workflows/rust.yml":"40fffdc3b5177dd3019cf6a94afb9700691b90ee2f712e5ae69337ee005bddb1",".github/workflows/sbom.yml":"9de74b8f79d0a88747d5ab08385b0a40b53134a574f019c7615414a166e2ae9e","CODE_OF_CONDUCT.md":"f7b4cba1deaa0a77bd611c04c84ef5b6859e44c8370f7513fe688fb9531b913b","Cargo.lock":"7677a765d090efdecc800b9fd672e47dba8496505bfb31a8d512ca592119123a","Cargo.toml":"7dae0463e54971f7b6f4c213a7dcc7a6d75a51bce08e38028f7fce566d27625c","LICENSE-APACHE":"a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2","LICENSE-MIT":"4ad721b5b6a3d39ca3e2202f403d897c4a1d42896486dd58963a81f8e64ef61d","README.md":"ea07ce695cee320e0e053fa4dede6a6300aade43eece29a6a4b40d1ad114b420","deny.toml":"eb7287cd071f6f5ff285ad2bf96ebac0d328a4ddb55bb8c3967f6d737f2d43ed","src/id.rs":"3f025f8d61891dbebcabb19659640165e3eb37390325a9a50e1e13a415bd70c5","src/lib.rs":"1d1330f52cb47dfaf9ed83de4cab27320d3835700db7ca47cebc6cc776fc50de","tests/common/mod.rs":"f192ccded009dc2b421648671013f0dbe37e9b8ed1dbc90e2cb19dd3bbf60b34","tests/connection_attempts.rs":"b8337ef2044b60a634345f6df6e5203eadc85da2b93a9960a0c971ee3ea14c3c","tests/failure.rs":"31303e43825e492ddb87215c2480200d1768b350f2f9de7e12d17cfe4eafb19e","tests/hostname_resolution.rs":"ad2e103dd1f1ab301b2d8c0bfed1a6827987adf292fe1ee27e8b0479ca773459","tests/https_records.rs":"d84bff4dee30609db2110715283d164286b524303d55ad1e58f8348d88b55c69","tests/network_config.rs":"5a75ebb8db4bbc68f6bef3621dfcd932bdf6769cd410d8882293758ee4ce14bc","tests/type_impls.rs":"96a5b22d8c007ef679dc0f440fd982644b27bdb86e9362f06c0f4dd09596ca6b"},"package":null} \ No newline at end of file +{"files":{".codecov.yml":"81d2e8284c4c496f2d251510fe631509a89cfdf63a851a1b650e00de34eb7131",".github/CODEOWNERS":"fe246d8cd7003a28b9b250319c196db2051adaa15f0390a4a5e8d8a5315b0a51",".github/dependabot.yml":"91c1465d580d7ce968d40ae09ddc789a83bc6b5a8a622679071c63ad748bf207",".github/workflows/claude.yml":"47f6e2f725861de913a147e8d80bed28f51a2376798dd5aa97d9331a23089085",".github/workflows/mutants.yml":"c8f3dcafa9b560f9d87ff22f1923d6fc2fd340eb0863244113d2cd4c2891ad13",".github/workflows/rust.yml":"40fffdc3b5177dd3019cf6a94afb9700691b90ee2f712e5ae69337ee005bddb1",".github/workflows/sbom.yml":"9de74b8f79d0a88747d5ab08385b0a40b53134a574f019c7615414a166e2ae9e","CODE_OF_CONDUCT.md":"f7b4cba1deaa0a77bd611c04c84ef5b6859e44c8370f7513fe688fb9531b913b","Cargo.lock":"07d0fe03d8d031278d3d39730581c2e19264d3027b8c15247b7e578d1eed4b5c","Cargo.toml":"218166445cf58519cb72e32dbb777c8975d25f7089f8d9485a590c99f3a0069c","LICENSE-APACHE":"a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2","LICENSE-MIT":"4ad721b5b6a3d39ca3e2202f403d897c4a1d42896486dd58963a81f8e64ef61d","README.md":"ea07ce695cee320e0e053fa4dede6a6300aade43eece29a6a4b40d1ad114b420","deny.toml":"eb7287cd071f6f5ff285ad2bf96ebac0d328a4ddb55bb8c3967f6d737f2d43ed","src/id.rs":"3f025f8d61891dbebcabb19659640165e3eb37390325a9a50e1e13a415bd70c5","src/lib.rs":"2b3874b6743d9816627935098f5f987ff0396782c2855f248979e797639725c0","tests/common/mod.rs":"81db5b231e02e404892cec66a50ba5880740e328b17222d76cabd6df3cbbdf59","tests/connection_attempts.rs":"976b26fa3c695b664fbecbf746a77cce447649f22fb1a29ec06f44374f96da65","tests/failure.rs":"b72853297ff50d88e111a49941e7ff31f350031f4933f9b747088ed5a29384a1","tests/hostname_resolution.rs":"57b7b9d48f5fb8265d1ee18fc2569a7caa835e39a88b19d66327d9a843814faa","tests/https_records.rs":"acce31c1e01b94ef48913bcf4e18c9d2b574947afdc2bfb346dfbcf9529d0263","tests/https_rr_cname.rs":"88fb0cda127f3ca6c6b7a4f93aaa94d705339aac6af9cff6ae6c05666190d3f9","tests/network_config.rs":"c8a94f482f2e41b1c9aef77d42b2a544340aafe67383cd3cf27159aeeb2fa79b","tests/type_impls.rs":"bccc835a4d15a7335307f8b24c8812d551c0b1cc46f5cd5f21cb7b72daebf07f"},"package":null} \ No newline at end of file diff --git a/third_party/rust/happy-eyeballs/Cargo.lock b/third_party/rust/happy-eyeballs/Cargo.lock index 6867f59c5ce04..1f13659d30699 100644 --- a/third_party/rust/happy-eyeballs/Cargo.lock +++ b/third_party/rust/happy-eyeballs/Cargo.lock @@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "happy-eyeballs" -version = "0.6.0" +version = "0.7.0" dependencies = [ "env_logger", "log", diff --git a/third_party/rust/happy-eyeballs/Cargo.toml b/third_party/rust/happy-eyeballs/Cargo.toml index 4cc0f08f188b6..767e009ad1e5c 100644 --- a/third_party/rust/happy-eyeballs/Cargo.toml +++ b/third_party/rust/happy-eyeballs/Cargo.toml @@ -1,68 +1,14 @@ -# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO -# -# When uploading crates to the registry Cargo will automatically -# "normalize" Cargo.toml files for maximal compatibility -# with all versions of Cargo and also rewrite `path` dependencies -# to registry (e.g., crates.io) dependencies. -# -# If you are reading this file be aware that the original Cargo.toml -# will likely look very different (and much more reasonable). -# See Cargo.toml.orig for the original contents. - [package] +name = "happy-eyeballs" +version = "0.7.0" edition = "2024" rust-version = "1.85.0" -name = "happy-eyeballs" -version = "0.6.0" -build = false -autolib = false -autobins = false -autoexamples = false -autotests = false -autobenches = false -readme = "README.md" license = "MIT OR Apache-2.0" -[lib] -name = "happy_eyeballs" -path = "src/lib.rs" - -[[test]] -name = "connection_attempts" -path = "tests/connection_attempts.rs" - -[[test]] -name = "failure" -path = "tests/failure.rs" - -[[test]] -name = "hostname_resolution" -path = "tests/hostname_resolution.rs" - -[[test]] -name = "https_records" -path = "tests/https_records.rs" - -[[test]] -name = "network_config" -path = "tests/network_config.rs" - -[[test]] -name = "type_impls" -path = "tests/type_impls.rs" - -[dependencies.log] -version = "0.4" -default-features = false - -[dependencies.thiserror] -version = "2.0.12" -default-features = false - -[dependencies.url] -version = "2.5.7" -default-features = false +[dependencies] +log = { version = "0.4", default-features = false } +thiserror = { version = "2.0.12", default-features = false } +url = { version = "2.5.7", default-features = false } -[dev-dependencies.env_logger] -version = "0.10" -default-features = false +[dev-dependencies] +env_logger = { version = "0.10", default-features = false } \ No newline at end of file diff --git a/third_party/rust/happy-eyeballs/src/lib.rs b/third_party/rust/happy-eyeballs/src/lib.rs index a9cab52fd50ce..aef6fdc688a0a 100644 --- a/third_party/rust/happy-eyeballs/src/lib.rs +++ b/third_party/rust/happy-eyeballs/src/lib.rs @@ -42,7 +42,7 @@ //! //! // Later pass results as input back to the state machine, e.g. a DNS //! // response arrives: -//! # let dns_result = DnsResult::Aaaa(Ok(vec![Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)])); +//! # let dns_result = DnsResult::Aaaa(Ok(vec![Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)]), None); //! he.process_input(Input::DnsResult { id: dns_id.unwrap(), result: dns_result }, Instant::now()); //! ``` //! @@ -128,8 +128,14 @@ pub enum ConnectionResult { #[derive(Debug, Clone, PartialEq)] pub enum DnsResult { Https(Result, ()>), - Aaaa(Result, ()>), - A(Result, ()>), + /// AAAA answer plus the canonical name (final target of the CNAME chain) + /// reported by the resolver for this query, if any. The canonical name is + /// only consulted for the origin query, where it is used to drop HTTPS + /// records whose `TargetName` is inconsistent with it. + Aaaa(Result, ()>, Option), + /// A answer plus the canonical name reported by the resolver, if any. See + /// [`DnsResult::Aaaa`]. + A(Result, ()>, Option), } impl DnsResult { @@ -137,8 +143,8 @@ impl DnsResult { /// non-empty AAAA/A records or HTTPS records with IP hints. fn has_addrs(&self) -> bool { match self { - DnsResult::Aaaa(Ok(v)) => !v.is_empty(), - DnsResult::A(Ok(v)) => !v.is_empty(), + DnsResult::Aaaa(Ok(v), _) => !v.is_empty(), + DnsResult::A(Ok(v), _) => !v.is_empty(), DnsResult::Https(Ok(infos)) => infos .iter() .any(|i| !i.ipv4_hints.is_empty() || !i.ipv6_hints.is_empty()), @@ -146,13 +152,21 @@ impl DnsResult { } } + /// The canonical name reported by an A/AAAA answer, if any. + fn canonical_name(&self) -> Option<&TargetName> { + match self { + DnsResult::Aaaa(_, cname) | DnsResult::A(_, cname) => cname.as_ref(), + DnsResult::Https(_) => None, + } + } + fn ip_addrs(&self) -> impl Iterator + '_ { let v6 = match self { - DnsResult::Aaaa(Ok(addrs)) => addrs.as_slice(), + DnsResult::Aaaa(Ok(addrs), _) => addrs.as_slice(), _ => &[], }; let v4 = match self { - DnsResult::A(Ok(addrs)) => addrs.as_slice(), + DnsResult::A(Ok(addrs), _) => addrs.as_slice(), _ => &[], }; v6.iter() @@ -205,6 +219,19 @@ impl Debug for TargetName { } } +/// Compares two DNS names for the HTTPS-RR / CNAME consistency check. +/// +/// DNS names are case-insensitive (RFC 4343) and the representation may or may +/// not carry a trailing dot depending on the source (an HTTPS record's +/// `TargetName` versus a resolver-reported canonical name). Both are normalised +/// before comparison so e.g. `Svc.Example.Com.` matches `svc.example.com`. +fn target_names_match(a: &str, b: &str) -> bool { + fn normalize(s: &str) -> &str { + s.strip_suffix('.').unwrap_or(s) + } + normalize(a).eq_ignore_ascii_case(normalize(b)) +} + /// Output events from the Happy Eyeballs state machine #[derive(Debug, Clone, PartialEq)] #[must_use] @@ -670,6 +697,16 @@ enum Host { Domain(String), } +impl Host { + /// The domain, or `None` when connecting to an IP literal. + fn domain(&self) -> Option<&str> { + match self { + Host::Domain(d) => Some(d.as_str()), + Host::Ip(_) => None, + } + } +} + impl From for Host { fn from(host: UrlHost) -> Self { match host { @@ -940,23 +977,18 @@ impl HappyEyeballs { fn send_dns_request_for_target_name(&mut self) -> Option { let any_ech = self.any_ech(); - let target_names = self - .dns_queries - .iter() - .filter_map(|q| match &q.state { - DnsQueryState::Completed { - response: DnsResult::Https(Ok(service_infos)), - .. - } => Some(service_infos.iter()), - _ => None, - }) - .flatten() + let target_names: Vec = self + .usable_service_infos() + .into_iter() + // CNAME-inconsistent ServiceInfos are already excluded by usable_service_infos. // When any ServiceInfo has ECH, skip resolving targets without ECH. - .filter(move |i| !any_ech || i.ech_config.is_some()) - .map(|i| &i.target_name); + .filter(|i| !any_ech || i.ech_config.is_some()) + .map(|i| i.target_name.clone()) + .collect(); // Next AAAA or A query, respecting single-stack preferences. let (target_name, record_type) = target_names + .iter() .flat_map(|tn| { self.network_config .ip @@ -1197,18 +1229,11 @@ impl HappyEyeballs { fn endpoints_to_attempt_domain(&self, origin_domain: &str) -> Vec { let any_ech = self.any_ech(); - // Collect all ServiceInfos sorted by priority. + // Collect usable ServiceInfos (CNAME-consistent, per config) sorted by + // priority. let mut service_infos: Vec<&ServiceInfo> = self - .dns_queries - .iter() - .filter_map(|q| match &q.state { - DnsQueryState::Completed { - response: DnsResult::Https(Ok(infos)), - .. - } => Some(infos.as_slice()), - _ => None, - }) - .flatten() + .usable_service_infos() + .into_iter() // When at least one ServiceInfo has ECH config, skip those without it // and skip the origin fallback. .filter(|i| !any_ech || i.ech_config.is_some()) @@ -1222,7 +1247,7 @@ impl HappyEyeballs { let ipv4_addrs: Option<&[Ipv4Addr]> = self.dns_queries.iter().find_map(|q| match &q.state { DnsQueryState::Completed { - response: DnsResult::A(result), + response: DnsResult::A(result, _), .. } if q.target_name == info.target_name => { Some(result.as_deref().unwrap_or_default()) @@ -1232,7 +1257,7 @@ impl HappyEyeballs { let ipv6_addrs: Option<&[Ipv6Addr]> = self.dns_queries.iter().find_map(|q| match &q.state { DnsQueryState::Completed { - response: DnsResult::Aaaa(result), + response: DnsResult::Aaaa(result, _), .. } if q.target_name == info.target_name => { Some(result.as_deref().unwrap_or_default()) @@ -1260,7 +1285,7 @@ impl HappyEyeballs { .iter() .filter_map(|q| match &q.state { DnsQueryState::Completed { - response: r @ (DnsResult::Aaaa(_) | DnsResult::A(_)), + response: r @ (DnsResult::Aaaa(..) | DnsResult::A(..)), .. } if q.target_name.as_str() == origin_domain => Some(r), _ => None, @@ -1309,13 +1334,68 @@ impl HappyEyeballs { if !self.network_config.ech { return false; } - self.dns_queries.iter().any(|q| match &q.state { - DnsQueryState::Completed { - response: DnsResult::Https(Ok(infos)), - .. - } => infos.iter().any(|i| i.ech_config.is_some()), - _ => false, + self.usable_service_infos() + .iter() + .any(|i| i.ech_config.is_some()) + } + + /// Canonical names reported by the origin's completed A/AAAA resolutions. + /// + /// These drive the HTTPS-RR / CNAME consistency check. There may be more + /// than one (e.g. A and AAAA steering to different CDNs). + fn origin_canonical_names(&self, origin_domain: &str) -> Vec<&TargetName> { + self.dns_queries + .iter() + .filter(|q| q.target_name.as_str() == origin_domain) + .filter_map(|q| match &q.state { + DnsQueryState::Completed { response, .. } => response.canonical_name(), + DnsQueryState::InProgress => None, + }) + .collect() + } + + /// ServiceInfos from completed HTTPS responses that are usable for this + /// connection. + /// + /// When the origin's A/AAAA resolution reported a canonical name, + /// ServiceInfos whose `TargetName` is inconsistent with every reported + /// canonical name are dropped. A ServiceInfo is kept if its `TargetName` + /// matches the canonical name of either address family. When no canonical + /// name was reported, all ServiceInfos are returned unchanged. + /// + /// This runs before the ECH-based filtering so that dropping an + /// inconsistent ECH record lets the origin fallback re-engage, i.e. the + /// connection prefers the CNAME over a broken ECH target. + fn usable_service_infos(&self) -> Vec<&ServiceInfo> { + let all = self + .dns_queries + .iter() + .filter_map(|q| match &q.state { + DnsQueryState::Completed { + response: DnsResult::Https(Ok(infos)), + .. + } => Some(infos.iter()), + _ => None, + }) + .flatten(); + + let Some(origin) = self.host.domain() else { + return all.collect(); + }; + + let cnames = self.origin_canonical_names(origin); + if cnames.is_empty() { + // No canonical name reported: do not filter. + return all.collect(); + } + + // TODO: ideally we wouldn't allocate (i.e. not call collect) here. + all.filter(|i| { + cnames + .iter() + .any(|c| target_names_match(i.target_name.as_str(), c.as_str())) }) + .collect() } /// HTTP versions when the host is an IP address (no DNS involved). @@ -1335,20 +1415,9 @@ impl HappyEyeballs { let mut http_versions = HashSet::new(); http_versions.extend( - self.dns_queries + self.usable_service_infos() .iter() - .filter_map(|q| match &q.state { - DnsQueryState::Completed { - response: DnsResult::Https(Ok(infos)), - .. - } => Some( - infos - .iter() - .flat_map(|i| i.alpn_http_versions.iter().cloned()), - ), - _ => None, - }) - .flatten(), + .flat_map(|i| i.alpn_http_versions.iter().cloned()), ); if http_versions.is_empty() { @@ -1496,17 +1565,17 @@ mod tests { #[test] fn dns_result_has_addrs() { for result in [ - DnsResult::Aaaa(Ok(vec![])), - DnsResult::Aaaa(Err(())), - DnsResult::A(Ok(vec![])), - DnsResult::A(Err(())), + DnsResult::Aaaa(Ok(vec![]), None), + DnsResult::Aaaa(Err(()), None), + DnsResult::A(Ok(vec![]), None), + DnsResult::A(Err(()), None), DnsResult::Https(Err(())), DnsResult::Https(Ok(vec![])), ] { assert!(!result.has_addrs()); } - assert!(DnsResult::Aaaa(Ok(vec![Ipv6Addr::LOCALHOST])).has_addrs()); - assert!(DnsResult::A(Ok(vec![Ipv4Addr::LOCALHOST])).has_addrs()); + assert!(DnsResult::Aaaa(Ok(vec![Ipv6Addr::LOCALHOST]), None).has_addrs()); + assert!(DnsResult::A(Ok(vec![Ipv4Addr::LOCALHOST]), None).has_addrs()); } #[test] diff --git a/third_party/rust/happy-eyeballs/tests/common/mod.rs b/third_party/rust/happy-eyeballs/tests/common/mod.rs index 44eaab349630d..32496ad051365 100644 --- a/third_party/rust/happy-eyeballs/tests/common/mod.rs +++ b/third_party/rust/happy-eyeballs/tests/common/mod.rs @@ -156,28 +156,28 @@ pub fn in_dns_https_negative(id: Id) -> Input { pub fn in_dns_aaaa_positive(id: Id) -> Input { Input::DnsResult { id, - result: DnsResult::Aaaa(Ok(vec![V6_ADDR])), + result: DnsResult::Aaaa(Ok(vec![V6_ADDR]), None), } } pub fn in_dns_a_positive(id: Id) -> Input { Input::DnsResult { id, - result: DnsResult::A(Ok(vec![V4_ADDR])), + result: DnsResult::A(Ok(vec![V4_ADDR]), None), } } pub fn in_dns_aaaa_negative(id: Id) -> Input { Input::DnsResult { id, - result: DnsResult::Aaaa(Err(())), + result: DnsResult::Aaaa(Err(()), None), } } pub fn in_dns_a_negative(id: Id) -> Input { Input::DnsResult { id, - result: DnsResult::A(Err(())), + result: DnsResult::A(Err(()), None), } } @@ -202,36 +202,28 @@ pub fn in_connection_result_ech_retry(id: Id) -> Input { } } -pub fn out_send_dns_https(id: Id) -> Output { +pub fn out_send_dns(id: Id, hostname: &str, record_type: DnsRecordType) -> Output { Output::SendDnsQuery { id, - hostname: HOSTNAME.into(), - record_type: DnsRecordType::Https, + hostname: hostname.into(), + record_type, } } +pub fn out_send_dns_https(id: Id) -> Output { + out_send_dns(id, HOSTNAME, DnsRecordType::Https) +} + pub fn out_send_dns_aaaa(id: Id) -> Output { - Output::SendDnsQuery { - id, - hostname: HOSTNAME.into(), - record_type: DnsRecordType::Aaaa, - } + out_send_dns(id, HOSTNAME, DnsRecordType::Aaaa) } pub fn out_send_dns_svc1(id: Id) -> Output { - Output::SendDnsQuery { - id, - hostname: SVC1.into(), - record_type: DnsRecordType::Aaaa, - } + out_send_dns(id, SVC1, DnsRecordType::Aaaa) } pub fn out_send_dns_a(id: Id) -> Output { - Output::SendDnsQuery { - id, - hostname: HOSTNAME.into(), - record_type: DnsRecordType::A, - } + out_send_dns(id, HOSTNAME, DnsRecordType::A) } pub fn out_attempt_v6_h1_h2(id: Id) -> Output { @@ -393,3 +385,25 @@ pub fn setup_with_config(config: NetworkConfig) -> (Instant, HappyEyeballs) { let he = HappyEyeballs::new_with_network_config(HOSTNAME, PORT, config).unwrap(); (now, he) } + +/// Assert that the next output is a DNS query for `hostname`/`record_type` with `id`. +pub fn expect_query( + he: &mut HappyEyeballs, + now: Instant, + id: u64, + hostname: &str, + rt: DnsRecordType, +) { + assert_eq!( + he.process_output(now), + Some(out_send_dns(Id::from(id), hostname, rt)) + ); +} + +/// Assert the standard opening burst of DNS queries: HTTPS, AAAA, then A for the +/// default `HOSTNAME` with ids 0, 1, 2. +pub fn expect_initial_dns_queries(he: &mut HappyEyeballs, now: Instant) { + expect_query(he, now, 0, HOSTNAME, DnsRecordType::Https); + expect_query(he, now, 1, HOSTNAME, DnsRecordType::Aaaa); + expect_query(he, now, 2, HOSTNAME, DnsRecordType::A); +} diff --git a/third_party/rust/happy-eyeballs/tests/connection_attempts.rs b/third_party/rust/happy-eyeballs/tests/connection_attempts.rs index 134be694e973c..089fbc0e5c08e 100644 --- a/third_party/rust/happy-eyeballs/tests/connection_attempts.rs +++ b/third_party/rust/happy-eyeballs/tests/connection_attempts.rs @@ -15,11 +15,9 @@ use happy_eyeballs::{ fn ipv6_blackhole() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_positive(Id::from(0))), Some(out_resolution_delay()), @@ -51,11 +49,9 @@ fn ipv6_blackhole() { fn connection_attempt_delay() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_positive_no_alpn(Id::from(0))), Some(out_resolution_delay()), @@ -81,11 +77,9 @@ fn connection_attempt_delay() { fn never_try_same_attempt_twice() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_negative(Id::from(0))), Some(out_resolution_delay()), @@ -111,11 +105,9 @@ fn never_try_same_attempt_twice() { fn successful_connection_cancels_others() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_positive_no_alpn(Id::from(0))), Some(out_resolution_delay()), @@ -123,7 +115,7 @@ fn successful_connection_cancels_others() { ( Some(Input::DnsResult { id: Id::from(1), - result: DnsResult::Aaaa(Ok(vec![V6_ADDR, V6_ADDR_2])), + result: DnsResult::Aaaa(Ok(vec![V6_ADDR, V6_ADDR_2]), None), }), Some(out_attempt_v6_h1_h2(Id::from(3))), ), @@ -171,11 +163,9 @@ fn successful_connection_cancels_others() { fn failed_connection_tries_next_immediately() { let (now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_positive_no_alpn(Id::from(0))), Some(out_resolution_delay()), @@ -205,11 +195,9 @@ fn failed_connection_tries_next_immediately() { fn successful_connection_emits_succeeded() { let (now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_positive_no_alpn(Id::from(0))), Some(out_resolution_delay()), @@ -231,11 +219,9 @@ fn successful_connection_emits_succeeded() { fn succeeded_keeps_emitting_succeeded() { let (now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_positive_no_alpn(Id::from(0))), Some(out_resolution_delay()), @@ -267,11 +253,9 @@ fn connection_attempt_delay_partial_elapsed() { }); // Drive to first connection attempt at time T=now. + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_negative(Id::from(0))), Some(out_resolution_delay()), @@ -300,11 +284,9 @@ fn connection_attempt_delay_partial_elapsed() { fn cancelled_connection_result_ignored() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_positive_no_alpn(Id::from(0))), Some(out_resolution_delay()), diff --git a/third_party/rust/happy-eyeballs/tests/failure.rs b/third_party/rust/happy-eyeballs/tests/failure.rs index bfcb61aacddf8..b0825790999a6 100644 --- a/third_party/rust/happy-eyeballs/tests/failure.rs +++ b/third_party/rust/happy-eyeballs/tests/failure.rs @@ -12,11 +12,9 @@ use happy_eyeballs::{ fn all_dns_failed() { let (now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_negative(Id::from(0))), Some(out_resolution_delay()), @@ -39,11 +37,9 @@ fn all_dns_failed() { fn dns_partial_failure_then_connection_failed() { let (now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_negative(Id::from(0))), Some(out_resolution_delay()), @@ -70,11 +66,9 @@ fn dns_partial_failure_then_connection_failed() { fn all_connections_failed() { let (now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_positive_no_alpn(Id::from(0))), Some(out_resolution_delay()), @@ -136,11 +130,9 @@ fn ip_host_connection_failure() { fn first_connection_fails_second_succeeds() { let (now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_positive_no_alpn(Id::from(0))), Some(out_resolution_delay()), diff --git a/third_party/rust/happy-eyeballs/tests/hostname_resolution.rs b/third_party/rust/happy-eyeballs/tests/hostname_resolution.rs index cf6781a0cb1b2..8748713ca5b0a 100644 --- a/third_party/rust/happy-eyeballs/tests/hostname_resolution.rs +++ b/third_party/rust/happy-eyeballs/tests/hostname_resolution.rs @@ -21,13 +21,9 @@ fn expect_hints_move_on_with_timeout( https_input: Input, expected_attempt: Output, ) { + expect_initial_dns_queries(he, *now); he.expect( - vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), - (Some(https_input), Some(out_resolution_delay())), - ], + vec![(Some(https_input), Some(out_resolution_delay()))], *now, ); *now += RESOLUTION_DELAY; @@ -55,14 +51,7 @@ fn initial_state() { fn sendig_dns_queries() { let (now, mut he) = setup(); - he.expect( - vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), - ], - now, - ); + expect_initial_dns_queries(&mut he, now); } /// > Implementations SHOULD NOT wait for all answers to return before @@ -73,11 +62,9 @@ fn sendig_dns_queries() { fn dont_wait_for_all_dns_answers() { let (now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_positive(Id::from(0))), Some(out_resolution_delay()), @@ -188,11 +175,9 @@ fn move_on_non_timeout() { ] { let (now, mut he) = setup_with_config(test_case.address_family.clone()); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(test_case.positive.clone()), Some(out_resolution_delay()), @@ -217,16 +202,12 @@ fn move_on_non_timeout() { fn move_on_timeout() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( - vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), - ( - Some(in_dns_a_positive(Id::from(2))), - Some(out_resolution_delay()), - ), - ], + vec![( + Some(in_dns_a_positive(Id::from(2))), + Some(out_resolution_delay()), + )], now, ); @@ -243,11 +224,9 @@ fn move_on_timeout() { fn resolution_delay_starts_after_other_response() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), // No other response received yet. (None, None), ( @@ -274,11 +253,9 @@ fn resolution_delay_starts_on_first_response() { const RESPONSE_DELAY: Duration = Duration::from_millis(10); let (start, mut he) = setup(); + expect_initial_dns_queries(&mut he, start); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), // No other response received yet. (None, None), ], @@ -329,16 +306,12 @@ fn resolution_delay_starts_on_first_response() { fn https_hints() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( - vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), - ( - Some(in_dns_https_positive_v4_and_v6_hints(Id::from(0))), - Some(out_resolution_delay()), - ), - ], + vec![( + Some(in_dns_https_positive_v4_and_v6_hints(Id::from(0))), + Some(out_resolution_delay()), + )], now, ); @@ -407,11 +380,9 @@ fn https_v4_hints_move_on_with_timeout() { fn resolution_delay_boundary() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), // HTTPS negative and A positive arrive at T; AAAA still pending. ( Some(in_dns_https_negative(Id::from(0))), @@ -446,16 +417,12 @@ fn resolution_delay_boundary() { fn https_hints_still_query_a_aaaa() { let (now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( - vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), - ( - Some(in_dns_https_positive_svc1(Id::from(0))), - Some(out_send_dns_svc1(Id::from(3))), - ), - ], + vec![( + Some(in_dns_https_positive_svc1(Id::from(0))), + Some(out_send_dns_svc1(Id::from(3))), + )], now, ); } @@ -464,11 +431,9 @@ fn https_hints_still_query_a_aaaa() { fn https_h3_upgrade_without_hints() { let (now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_aaaa_positive(Id::from(1))), Some(out_resolution_delay()), @@ -495,11 +460,9 @@ fn https_h3_disabled() { ..NetworkConfig::default() }); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_aaaa_positive(Id::from(1))), Some(out_resolution_delay()), @@ -517,11 +480,9 @@ fn https_h3_disabled() { fn multiple_ips_per_record() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_negative(Id::from(0))), Some(out_resolution_delay()), @@ -533,7 +494,7 @@ fn multiple_ips_per_record() { ( Some(Input::DnsResult { id: Id::from(1), - result: DnsResult::Aaaa(Ok(vec![V6_ADDR, V6_ADDR_2, V6_ADDR_3])), + result: DnsResult::Aaaa(Ok(vec![V6_ADDR, V6_ADDR_2, V6_ADDR_3]), None), }), Some(out_attempt_v6_h1_h2(Id::from(3))), ), @@ -631,11 +592,7 @@ fn single_stack_target_name_skips_disabled_address_family() { Case { ip: IpPreference::Ipv4Only, origin_dns_query: out_send_dns_a(Id::from(1)), - target_name_dns_query: Output::SendDnsQuery { - id: Id::from(2), - hostname: SVC1.into(), - record_type: DnsRecordType::A, - }, + target_name_dns_query: out_send_dns(Id::from(2), SVC1, DnsRecordType::A), }, ]; diff --git a/third_party/rust/happy-eyeballs/tests/https_records.rs b/third_party/rust/happy-eyeballs/tests/https_records.rs index f231393be2c85..19fcc729242a3 100644 --- a/third_party/rust/happy-eyeballs/tests/https_records.rs +++ b/third_party/rust/happy-eyeballs/tests/https_records.rs @@ -21,27 +21,23 @@ fn ech_config_propagated_to_endpoint() { // HTTPS arrives with an ECH config and a v6 hint while AAAA and A are // still in-flight. After the resolution delay the hint is used, and the // ECH config must be carried onto the endpoint. + expect_initial_dns_queries(&mut he, now); he.expect( - vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), - ( - Some(Input::DnsResult { - id: Id::from(0), - result: DnsResult::Https(Ok(vec![ServiceInfo { - priority: 1, - target_name: HOSTNAME.into(), - alpn_http_versions: HashSet::from([HttpVersion::H3, HttpVersion::H2]), - ipv6_hints: vec![V6_ADDR], - ipv4_hints: vec![], - ech_config: Some(ech_config()), - port: None, - }])), - }), - Some(out_resolution_delay()), - ), - ], + vec![( + Some(Input::DnsResult { + id: Id::from(0), + result: DnsResult::Https(Ok(vec![ServiceInfo { + priority: 1, + target_name: HOSTNAME.into(), + alpn_http_versions: HashSet::from([HttpVersion::H3, HttpVersion::H2]), + ipv6_hints: vec![V6_ADDR], + ipv4_hints: vec![], + ech_config: Some(ech_config()), + port: None, + }])), + }), + Some(out_resolution_delay()), + )], now, ); @@ -116,11 +112,9 @@ fn hints_discarded_on_negative_answer() { for case in cases { let (mut now, mut he) = setup_with_config(case.config); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), (Some(case.first_arrives), Some(out_resolution_delay())), (Some(case.second_arrives), Some(out_resolution_delay())), ( @@ -163,11 +157,9 @@ fn ech_disabled() { ..NetworkConfig::default() }); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_a_negative(Id::from(2))), Some(out_resolution_delay()), @@ -224,11 +216,9 @@ fn ech_disabled() { fn ech_config_from_https_applies_to_aaaa() { let (now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(Input::DnsResult { id: Id::from(0), @@ -265,11 +255,9 @@ fn ech_config_from_https_applies_to_aaaa() { fn multiple_target_names() { let (now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), // HTTPS response with a different target name ( Some(in_dns_https_positive_svc1(Id::from(0))), @@ -319,11 +307,9 @@ fn partial_ech_two_service_infos() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(Input::DnsResult { id: Id::from(0), @@ -349,19 +335,11 @@ fn partial_ech_two_service_infos() { ])), }), // Only SVC1 gets DNS queries — SVC2 is skipped (no ECH) - Some(Output::SendDnsQuery { - id: Id::from(3), - hostname: SVC1.into(), - record_type: DnsRecordType::Aaaa, - }), + Some(out_send_dns(Id::from(3), SVC1, DnsRecordType::Aaaa)), ), ( None, - Some(Output::SendDnsQuery { - id: Id::from(4), - hostname: SVC1.into(), - record_type: DnsRecordType::A, - }), + Some(out_send_dns(Id::from(4), SVC1, DnsRecordType::A)), ), (None, Some(out_resolution_delay())), // HOSTNAME AAAA positive -> move-on criteria met, but SVC1 has no @@ -383,7 +361,7 @@ fn partial_ech_two_service_infos() { ( Some(Input::DnsResult { id: Id::from(4), - result: DnsResult::A(Ok(vec![V4_ADDR_2])), + result: DnsResult::A(Ok(vec![V4_ADDR_2]), None), }), Some(Output::AttemptConnection { id: Id::from(5), @@ -442,11 +420,9 @@ fn both_service_infos_have_ech_no_origin_fallback() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(Input::DnsResult { id: Id::from(0), @@ -472,35 +448,19 @@ fn both_service_infos_have_ech_no_origin_fallback() { ])), }), // Both SVC1 and SVC2 get DNS queries (both have ECH) - Some(Output::SendDnsQuery { - id: Id::from(3), - hostname: SVC1.into(), - record_type: DnsRecordType::Aaaa, - }), + Some(out_send_dns(Id::from(3), SVC1, DnsRecordType::Aaaa)), ), ( None, - Some(Output::SendDnsQuery { - id: Id::from(4), - hostname: SVC1.into(), - record_type: DnsRecordType::A, - }), + Some(out_send_dns(Id::from(4), SVC1, DnsRecordType::A)), ), ( None, - Some(Output::SendDnsQuery { - id: Id::from(5), - hostname: SVC2.into(), - record_type: DnsRecordType::Aaaa, - }), + Some(out_send_dns(Id::from(5), SVC2, DnsRecordType::Aaaa)), ), ( None, - Some(Output::SendDnsQuery { - id: Id::from(6), - hostname: SVC2.into(), - record_type: DnsRecordType::A, - }), + Some(out_send_dns(Id::from(6), SVC2, DnsRecordType::A)), ), (None, Some(out_resolution_delay())), // HOSTNAME AAAA/A positive — but fallback will be skipped (no ECH) @@ -521,7 +481,7 @@ fn both_service_infos_have_ech_no_origin_fallback() { ( Some(Input::DnsResult { id: Id::from(4), - result: DnsResult::A(Ok(vec![V4_ADDR_2])), + result: DnsResult::A(Ok(vec![V4_ADDR_2]), None), }), Some(Output::AttemptConnection { id: Id::from(7), @@ -543,7 +503,7 @@ fn both_service_infos_have_ech_no_origin_fallback() { ( Some(Input::DnsResult { id: Id::from(6), - result: DnsResult::A(Ok(vec![V4_ADDR])), + result: DnsResult::A(Ok(vec![V4_ADDR]), None), }), Some(out_connection_attempt_delay()), ), @@ -622,11 +582,9 @@ fn partial_ech_with_alt_svc() { }; let (mut now, mut he) = setup_with_config(config); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(Input::DnsResult { id: Id::from(0), @@ -652,19 +610,11 @@ fn partial_ech_with_alt_svc() { ])), }), // Only SVC1 gets DNS queries — SVC2 skipped (no ECH) - Some(Output::SendDnsQuery { - id: Id::from(3), - hostname: SVC1.into(), - record_type: DnsRecordType::Aaaa, - }), + Some(out_send_dns(Id::from(3), SVC1, DnsRecordType::Aaaa)), ), ( None, - Some(Output::SendDnsQuery { - id: Id::from(4), - hostname: SVC1.into(), - record_type: DnsRecordType::A, - }), + Some(out_send_dns(Id::from(4), SVC1, DnsRecordType::A)), ), (None, Some(out_resolution_delay())), // HOSTNAME AAAA/A positive @@ -685,7 +635,7 @@ fn partial_ech_with_alt_svc() { ( Some(Input::DnsResult { id: Id::from(4), - result: DnsResult::A(Ok(vec![V4_ADDR_2])), + result: DnsResult::A(Ok(vec![V4_ADDR_2]), None), }), Some(Output::AttemptConnection { id: Id::from(5), @@ -732,27 +682,23 @@ mod https_port_svcparam_overrides_port_for { // HTTPS arrives with port=8443 while AAAA and A are still in-flight. // After the resolution delay the hint is used; the connection attempt // must use 8443, not the authority port 443. IPv6 is preferred. + expect_initial_dns_queries(&mut he, now); he.expect( - vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), - ( - Some(Input::DnsResult { - id: Id::from(0), - result: DnsResult::Https(Ok(vec![ServiceInfo { - priority: 1, - target_name: HOSTNAME.into(), - alpn_http_versions: HashSet::from([HttpVersion::H3, HttpVersion::H2]), - ipv6_hints: vec![V6_ADDR], - ipv4_hints, - ech_config: None, - port: Some(CUSTOM_PORT), - }])), - }), - Some(out_resolution_delay()), - ), - ], + vec![( + Some(Input::DnsResult { + id: Id::from(0), + result: DnsResult::Https(Ok(vec![ServiceInfo { + priority: 1, + target_name: HOSTNAME.into(), + alpn_http_versions: HashSet::from([HttpVersion::H3, HttpVersion::H2]), + ipv6_hints: vec![V6_ADDR], + ipv4_hints, + ech_config: None, + port: Some(CUSTOM_PORT), + }])), + }), + Some(out_resolution_delay()), + )], now, ); @@ -780,11 +726,9 @@ mod https_port_svcparam_overrides_port_for { fn https_port_svcparam_applies_to_resolved_a_and_aaaa() { let (now, mut he) = setup(); // constructed with PORT (443) + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), // HTTPS record with port=8443, no hints ( Some(Input::DnsResult { @@ -824,11 +768,9 @@ fn https_port_svcparam_applies_to_resolved_a_and_aaaa() { fn https_port_svcparam_applies_but_fallbacks_follow() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), // HTTPS record with port=8443, no hints ( Some(Input::DnsResult { @@ -912,11 +854,9 @@ fn https_two_service_infos_with_different_ports() { } }; + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), // Two ServiceInfo records; the lower priority number wins first. ( Some(Input::DnsResult { @@ -994,11 +934,9 @@ fn https_two_service_infos_with_different_ports() { fn no_default_alpn() { let (now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_positive(Id::from(0))), Some(out_resolution_delay()), @@ -1045,11 +983,9 @@ fn no_default_alpn() { fn https_svc1_addresses_trigger_additional_attempts() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(Input::DnsResult { id: Id::from(0), @@ -1074,19 +1010,11 @@ fn https_svc1_addresses_trigger_additional_attempts() { }, ])), }), - Some(Output::SendDnsQuery { - id: Id::from(3), - hostname: SVC1.into(), - record_type: DnsRecordType::Aaaa, - }), + Some(out_send_dns(Id::from(3), SVC1, DnsRecordType::Aaaa)), ), ( None, - Some(Output::SendDnsQuery { - id: Id::from(4), - hostname: SVC1.into(), - record_type: DnsRecordType::A, - }), + Some(out_send_dns(Id::from(4), SVC1, DnsRecordType::A)), ), (None, Some(out_resolution_delay())), ( @@ -1100,14 +1028,14 @@ fn https_svc1_addresses_trigger_additional_attempts() { ( Some(Input::DnsResult { id: Id::from(3), - result: DnsResult::Aaaa(Ok(vec![V6_ADDR_2])), + result: DnsResult::Aaaa(Ok(vec![V6_ADDR_2]), None), }), Some(out_connection_attempt_delay()), ), ( Some(Input::DnsResult { id: Id::from(4), - result: DnsResult::A(Ok(vec![V4_ADDR_2])), + result: DnsResult::A(Ok(vec![V4_ADDR_2]), None), }), Some(out_connection_attempt_delay()), ), @@ -1169,11 +1097,9 @@ fn https_port_takes_precedence_over_alt_svc_port() { }; let (mut now, mut he) = setup_with_config(config); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), // HTTPS record with port=8443 ( Some(Input::DnsResult { @@ -1281,11 +1207,9 @@ fn https_port_takes_precedence_over_alt_svc_port() { fn target_name_redirect_addresses_used_in_connection_attempts() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), // HTTPS response redirects to SVC1 (different target name, no hints) ( Some(Input::DnsResult { @@ -1301,19 +1225,11 @@ fn target_name_redirect_addresses_used_in_connection_attempts() { }])), }), // Follow-up DNS for the redirected target name - Some(Output::SendDnsQuery { - id: Id::from(3), - hostname: SVC1.into(), - record_type: DnsRecordType::Aaaa, - }), + Some(out_send_dns(Id::from(3), SVC1, DnsRecordType::Aaaa)), ), ( None, - Some(Output::SendDnsQuery { - id: Id::from(4), - hostname: SVC1.into(), - record_type: DnsRecordType::A, - }), + Some(out_send_dns(Id::from(4), SVC1, DnsRecordType::A)), ), (None, Some(out_resolution_delay())), // SVC1 AAAA positive → move-on criteria met, first attempt uses @@ -1321,7 +1237,7 @@ fn target_name_redirect_addresses_used_in_connection_attempts() { ( Some(Input::DnsResult { id: Id::from(3), - result: DnsResult::Aaaa(Ok(vec![V6_ADDR_2])), + result: DnsResult::Aaaa(Ok(vec![V6_ADDR_2]), None), }), Some(Output::AttemptConnection { id: Id::from(5), @@ -1338,7 +1254,7 @@ fn target_name_redirect_addresses_used_in_connection_attempts() { ( Some(Input::DnsResult { id: Id::from(4), - result: DnsResult::A(Ok(vec![V4_ADDR_2])), + result: DnsResult::A(Ok(vec![V4_ADDR_2]), None), }), Some(out_connection_attempt_delay()), ), @@ -1393,11 +1309,9 @@ fn target_name_redirect_addresses_used_in_connection_attempts() { fn https_fallback_uses_default_http_versions() { let (mut now, mut he) = setup(); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), // HTTPS record with port=8443, alpn=h3 only ( Some(Input::DnsResult { @@ -1446,11 +1360,9 @@ fn ech_retry_same_endpoint() { let new_ech_config = EchConfig::new(vec![10, 20, 30, 40, 50]); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(Input::DnsResult { id: Id::from(0), @@ -1514,11 +1426,9 @@ fn ech_retry_without_ech_sets_flag() { let empty_ech_config = EchConfig::new(vec![]); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(Input::DnsResult { id: Id::from(0), @@ -1581,11 +1491,9 @@ fn ech_retry_no_infinite_loop() { let retry_ech_config = EchConfig::new(vec![10, 20, 30, 40, 50]); let retry_ech_config_2 = EchConfig::new(vec![60, 70, 80]); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(Input::DnsResult { id: Id::from(0), diff --git a/third_party/rust/happy-eyeballs/tests/https_rr_cname.rs b/third_party/rust/happy-eyeballs/tests/https_rr_cname.rs new file mode 100644 index 0000000000000..f4d8ed0cc9db6 --- /dev/null +++ b/third_party/rust/happy-eyeballs/tests/https_rr_cname.rs @@ -0,0 +1,493 @@ +//! Tests for the HTTPS-RR / CNAME consistency check. +//! +//! When the origin's A/AAAA resolution reports a canonical name, an HTTPS +//! record whose `TargetName` is inconsistent with that canonical name is +//! dropped, and the connection prefers the origin's plain A/AAAA addresses. +//! This behaviour was originally introduced in Firefox to prevent broken ECH +//! handshakes when dual-CDN steering points the HTTPS record at one CDN while +//! the A/AAAA CNAME chain steers to another. + +mod common; +use common::*; + +use std::{ + collections::HashSet, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + time::Instant, +}; + +use happy_eyeballs::{ + ConnectionResult, DnsRecordType, DnsResult, Endpoint, HappyEyeballs, HttpVersion, Id, Input, + NetworkConfig, Output, ServiceInfo, +}; + +/// HTTPS record `TargetName`: a CDN distinct from the origin, carrying ECH. +const CDN_A: &str = "cdn-a.example.net."; +/// A different CDN the origin's CNAME chain might steer to. +const CDN_B: &str = "cdn-b.example.net."; + +/// Origin (example.com) addresses, reached via the plain A/AAAA / CNAME path. +const ORIGIN_V6: Ipv6Addr = V6_ADDR; +const ORIGIN_V4: Ipv4Addr = V4_ADDR; +/// Addresses the HTTPS record's TargetName (CDN_A) resolves to. +const CDN_A_V6: Ipv6Addr = V6_ADDR_2; +const CDN_A_V4: Ipv4Addr = V4_ADDR_2; + +/// A single ECH-bearing HTTPS record pointing at `target`. +fn https_ech_record(target: &str) -> DnsResult { + DnsResult::Https(Ok(vec![ServiceInfo { + priority: 1, + target_name: target.into(), + alpn_http_versions: HashSet::from([HttpVersion::H2, HttpVersion::H3]), + ipv6_hints: vec![], + ipv4_hints: vec![], + ech_config: Some(ech_config()), + port: None, + }])) +} + +/// Case- and trailing-dot-insensitive name comparison, matching the crate's +/// own normalization, used by the test answer functions. +fn same_name(a: &str, b: &str) -> bool { + a.trim_end_matches('.') + .eq_ignore_ascii_case(b.trim_end_matches('.')) +} + +/// Drive the state machine to completion, answering each DNS query via +/// `answer` (in the order the machine emits them) and failing every connection +/// attempt so the next is produced. Returns the endpoints that were attempted. +fn run(config: NetworkConfig, answer: impl Fn(&str, DnsRecordType) -> DnsResult) -> Vec { + let mut now = Instant::now(); + let mut he = HappyEyeballs::new_with_network_config(HOSTNAME, PORT, config).unwrap(); + collect_attempts(&mut he, &mut now, Some(&answer)) +} + +/// Answers a DNS query for `(hostname, record_type)` with a [`DnsResult`]. +type AnswerFn<'a> = &'a dyn Fn(&str, DnsRecordType) -> DnsResult; + +/// Drive the machine, advancing timers, collecting connection attempts (failing +/// each), and optionally answering DNS queries via `answer`. Stops when the +/// machine stalls, succeeds, fails, or (when `answer` is `None`) emits an +/// unanswered DNS query. +fn collect_attempts( + he: &mut HappyEyeballs, + now: &mut Instant, + answer: Option, +) -> Vec { + let mut attempts = Vec::new(); + for _ in 0..10_000 { + match he.process_output(*now) { + Some(Output::AttemptConnection { id, endpoint, .. }) => { + attempts.push(endpoint); + he.process_input( + Input::ConnectionResult { + id, + result: ConnectionResult::Failure("fail".to_string()), + }, + *now, + ); + } + Some(Output::Timer { duration }) => *now += duration, + Some(Output::CancelConnection { .. }) => {} + Some(Output::SendDnsQuery { + id, + hostname, + record_type, + }) => { + let Some(answer) = answer else { + break; + }; + let hostname: String = hostname.into(); + let result = answer(&hostname, record_type); + he.process_input(Input::DnsResult { id, result }, *now); + } + Some(Output::Succeeded) | Some(Output::Failed(_)) | None => break, + } + } + attempts +} + +fn attempted_addrs(endpoints: &[Endpoint]) -> HashSet { + endpoints.iter().map(|e| e.address.ip()).collect() +} + +fn any_ech(endpoints: &[Endpoint]) -> bool { + endpoints.iter().any(|e| e.ech_config.is_some()) +} + +fn all_ech(endpoints: &[Endpoint]) -> bool { + !endpoints.is_empty() && endpoints.iter().all(|e| e.ech_config.is_some()) +} + +/// Answer function: origin resolves with canonical name `origin_cname`; the +/// HTTPS TargetName CDN_A resolves to its own addresses. +fn answer_with_origin_cname( + origin_cname: Option<&'static str>, +) -> impl Fn(&str, DnsRecordType) -> DnsResult { + move |hostname: &str, record_type: DnsRecordType| { + let cname = origin_cname.map(|c| c.into()); + match record_type { + DnsRecordType::Https => https_ech_record(CDN_A), + DnsRecordType::Aaaa if same_name(hostname, HOSTNAME) => { + DnsResult::Aaaa(Ok(vec![ORIGIN_V6]), cname) + } + DnsRecordType::A if same_name(hostname, HOSTNAME) => { + DnsResult::A(Ok(vec![ORIGIN_V4]), cname) + } + DnsRecordType::Aaaa if same_name(hostname, CDN_A) => { + DnsResult::Aaaa(Ok(vec![CDN_A_V6]), None) + } + DnsRecordType::A if same_name(hostname, CDN_A) => { + DnsResult::A(Ok(vec![CDN_A_V4]), None) + } + DnsRecordType::Aaaa => DnsResult::Aaaa(Err(()), None), + DnsRecordType::A => DnsResult::A(Err(()), None), + } + } +} + +/// Matching CNAME: the origin steers to the same CDN the HTTPS record points +/// at, so the record is used (ECH endpoints at the CDN's addresses). +#[test] +fn matching_cname_record_used() { + let attempts = run( + NetworkConfig::default(), + answer_with_origin_cname(Some(CDN_A)), + ); + + assert_eq!( + attempted_addrs(&attempts), + HashSet::from([CDN_A_V6.into(), CDN_A_V4.into()]), + ); + assert!( + all_ech(&attempts), + "ECH must be carried to the CDN endpoints" + ); +} + +/// Mismatching CNAME: the origin steers (via CNAME) to a different CDN than the +/// HTTPS record's TargetName. The record is dropped and we connect to the +/// origin's plain A/AAAA addresses without ECH ("prefer the CNAME"). +#[test] +fn mismatching_cname_record_dropped() { + let attempts = run( + NetworkConfig::default(), + answer_with_origin_cname(Some(CDN_B)), + ); + + assert_eq!( + attempted_addrs(&attempts), + HashSet::from([ORIGIN_V6.into(), ORIGIN_V4.into()]), + ); + assert!( + !any_ech(&attempts), + "the broken ECH target must not be attempted" + ); + let cdn: HashSet = HashSet::from([CDN_A_V6.into(), CDN_A_V4.into()]); + assert!( + attempted_addrs(&attempts).is_disjoint(&cdn), + "the CDN behind the dropped record must never be attempted" + ); +} + +/// No canonical name reported by the origin resolution: no filtering, the +/// record is used (mirrors legacy "empty cname => record stays usable"). +#[test] +fn empty_cname_no_filtering() { + let attempts = run(NetworkConfig::default(), answer_with_origin_cname(None)); + + assert_eq!( + attempted_addrs(&attempts), + HashSet::from([CDN_A_V6.into(), CDN_A_V4.into()]), + ); + assert!(all_ech(&attempts)); +} + +/// Case- and trailing-dot-insensitive matching: the HTTPS TargetName and the +/// reported canonical name differ only in case and trailing dot, yet match. +#[test] +fn cname_match_is_case_and_trailing_dot_insensitive() { + let target = "CDN-A.Example.NET"; // mixed case, no trailing dot + let origin_cname = "cdn-a.example.net."; // lower case, trailing dot + + let attempts = run( + NetworkConfig::default(), + move |hostname: &str, record_type| match record_type { + DnsRecordType::Https => https_ech_record(target), + DnsRecordType::Aaaa if same_name(hostname, HOSTNAME) => { + DnsResult::Aaaa(Ok(vec![ORIGIN_V6]), Some(origin_cname.into())) + } + DnsRecordType::A if same_name(hostname, HOSTNAME) => { + DnsResult::A(Ok(vec![ORIGIN_V4]), Some(origin_cname.into())) + } + DnsRecordType::Aaaa if same_name(hostname, target) => { + DnsResult::Aaaa(Ok(vec![CDN_A_V6]), None) + } + DnsRecordType::A if same_name(hostname, target) => { + DnsResult::A(Ok(vec![CDN_A_V4]), None) + } + DnsRecordType::Aaaa => DnsResult::Aaaa(Err(()), None), + DnsRecordType::A => DnsResult::A(Err(()), None), + }, + ); + + assert_eq!( + attempted_addrs(&attempts), + HashSet::from([CDN_A_V6.into(), CDN_A_V4.into()]), + "names matching modulo case/trailing-dot must be treated as consistent" + ); + assert!(all_ech(&attempts)); +} + +/// A and AAAA report different canonical names. A ServiceInfo passes if its +/// TargetName matches EITHER family's canonical name. Here AAAA steers to +/// CDN_A (the record's target) while A steers to CDN_B, so the record is kept. +#[test] +fn passes_when_matching_either_address_family() { + let attempts = run(NetworkConfig::default(), |hostname: &str, record_type| { + match record_type { + DnsRecordType::Https => https_ech_record(CDN_A), + // AAAA canonical name matches the record's target. + DnsRecordType::Aaaa if same_name(hostname, HOSTNAME) => { + DnsResult::Aaaa(Ok(vec![ORIGIN_V6]), Some(CDN_A.into())) + } + // A canonical name does not. + DnsRecordType::A if same_name(hostname, HOSTNAME) => { + DnsResult::A(Ok(vec![ORIGIN_V4]), Some(CDN_B.into())) + } + DnsRecordType::Aaaa if same_name(hostname, CDN_A) => { + DnsResult::Aaaa(Ok(vec![CDN_A_V6]), None) + } + DnsRecordType::A if same_name(hostname, CDN_A) => { + DnsResult::A(Ok(vec![CDN_A_V4]), None) + } + DnsRecordType::Aaaa => DnsResult::Aaaa(Err(()), None), + DnsRecordType::A => DnsResult::A(Err(()), None), + } + }); + + assert_eq!( + attempted_addrs(&attempts), + HashSet::from([CDN_A_V6.into(), CDN_A_V4.into()]), + "matching either family's canonical name keeps the record" + ); + assert!(all_ech(&attempts)); +} + +/// Arrival order: origin A/AAAA (with the mismatching canonical name) arrive +/// BEFORE the HTTPS record. The record is filtered as soon as it arrives. +#[test] +fn order_origin_before_https_filters_record() { + let mut now = Instant::now(); + let mut he = + HappyEyeballs::new_with_network_config(HOSTNAME, PORT, NetworkConfig::default()).unwrap(); + + expect_query(&mut he, now, 0, HOSTNAME, DnsRecordType::Https); + expect_query(&mut he, now, 1, HOSTNAME, DnsRecordType::Aaaa); + expect_query(&mut he, now, 2, HOSTNAME, DnsRecordType::A); + + // Origin A/AAAA first, reporting the mismatching canonical name. + he.process_input( + Input::DnsResult { + id: Id::from(1), + result: DnsResult::Aaaa(Ok(vec![ORIGIN_V6]), Some(CDN_B.into())), + }, + now, + ); + he.process_input( + Input::DnsResult { + id: Id::from(2), + result: DnsResult::A(Ok(vec![ORIGIN_V4]), Some(CDN_B.into())), + }, + now, + ); + // HTTPS record arrives last; it is inconsistent with the known CNAME. + he.process_input( + Input::DnsResult { + id: Id::from(0), + result: https_ech_record(CDN_A), + }, + now, + ); + + let attempts = collect_attempts(&mut he, &mut now, None); + assert_eq!( + attempted_addrs(&attempts), + HashSet::from([ORIGIN_V6.into(), ORIGIN_V4.into()]), + ); + assert!(!any_ech(&attempts)); +} + +/// Arrival order: the HTTPS record arrives BEFORE the origin A/AAAA, so the +/// canonical name is not yet known and the target is fanned out. Once the +/// origin A/AAAA arrive with the mismatching canonical name, the record is +/// filtered retroactively and we prefer the origin addresses. +#[test] +fn order_https_before_origin_filters_record_retroactively() { + let mut now = Instant::now(); + let mut he = + HappyEyeballs::new_with_network_config(HOSTNAME, PORT, NetworkConfig::default()).unwrap(); + + expect_query(&mut he, now, 0, HOSTNAME, DnsRecordType::Https); + expect_query(&mut he, now, 1, HOSTNAME, DnsRecordType::Aaaa); + expect_query(&mut he, now, 2, HOSTNAME, DnsRecordType::A); + + // HTTPS first: with no canonical name yet, the target is treated as usable + // and gets fanned out (ids 3, 4). + he.process_input( + Input::DnsResult { + id: Id::from(0), + result: https_ech_record(CDN_A), + }, + now, + ); + expect_query(&mut he, now, 3, CDN_A, DnsRecordType::Aaaa); + expect_query(&mut he, now, 4, CDN_A, DnsRecordType::A); + + // Origin A/AAAA arrive with the mismatching canonical name. + he.process_input( + Input::DnsResult { + id: Id::from(1), + result: DnsResult::Aaaa(Ok(vec![ORIGIN_V6]), Some(CDN_B.into())), + }, + now, + ); + he.process_input( + Input::DnsResult { + id: Id::from(2), + result: DnsResult::A(Ok(vec![ORIGIN_V4]), Some(CDN_B.into())), + }, + now, + ); + // The (now irrelevant) target answers complete too. + he.process_input( + Input::DnsResult { + id: Id::from(3), + result: DnsResult::Aaaa(Err(()), None), + }, + now, + ); + he.process_input( + Input::DnsResult { + id: Id::from(4), + result: DnsResult::A(Err(()), None), + }, + now, + ); + + let attempts = collect_attempts(&mut he, &mut now, None); + assert_eq!( + attempted_addrs(&attempts), + HashSet::from([ORIGIN_V6.into(), ORIGIN_V4.into()]), + "the record is filtered retroactively once the CNAME is known" + ); + assert!(!any_ech(&attempts)); + let cdn: HashSet = HashSet::from([CDN_A_V6.into(), CDN_A_V4.into()]); + assert!(attempted_addrs(&attempts).is_disjoint(&cdn)); +} + +/// HTTPS arrives first, an in-progress connection attempt to the CDN target is +/// started, then the origin AAAA arrives with a mismatching canonical name. +/// The in-progress attempt is NOT cancelled; the HTTPS record is ignored for +/// any subsequent attempts. +#[test] +fn in_progress_attempt_continues_after_retroactive_cname_filter() { + let mut now = Instant::now(); + let mut he = + HappyEyeballs::new_with_network_config(HOSTNAME, PORT, NetworkConfig::default()).unwrap(); + + expect_query(&mut he, now, 0, HOSTNAME, DnsRecordType::Https); + expect_query(&mut he, now, 1, HOSTNAME, DnsRecordType::Aaaa); + expect_query(&mut he, now, 2, HOSTNAME, DnsRecordType::A); + + // HTTPS arrives first; fan out A/AAAA for CDN_A. + he.process_input( + Input::DnsResult { + id: Id::from(0), + result: https_ech_record(CDN_A), + }, + now, + ); + expect_query(&mut he, now, 3, CDN_A, DnsRecordType::Aaaa); + expect_query(&mut he, now, 4, CDN_A, DnsRecordType::A); + + // CDN_A target resolves; once both addresses are available the machine + // starts connecting immediately (no resolution-delay timer needed). + he.process_input( + Input::DnsResult { + id: Id::from(3), + result: DnsResult::Aaaa(Ok(vec![CDN_A_V6]), None), + }, + now, + ); + he.process_input( + Input::DnsResult { + id: Id::from(4), + result: DnsResult::A(Ok(vec![CDN_A_V4]), None), + }, + now, + ); + + // First connection attempt (CDN_A with ECH) — before mismatch is known. + let first_attempt = match he.process_output(now) { + Some(Output::AttemptConnection { id, endpoint, .. }) => { + assert!( + endpoint.ech_config.is_some(), + "first attempt must carry ECH" + ); + assert_eq!( + endpoint.address.ip(), + IpAddr::from(CDN_A_V6), + "first attempt must target CDN_A" + ); + id + } + other => panic!("expected AttemptConnection, got {other:?}"), + }; + + // Origin AAAA arrives with a mismatching CNAME — CDN_B, not CDN_A. + he.process_input( + Input::DnsResult { + id: Id::from(1), + result: DnsResult::Aaaa(Ok(vec![ORIGIN_V6]), Some(CDN_B.into())), + }, + now, + ); + + // The in-progress CDN_A attempt must NOT be cancelled. + let next = he.process_output(now); + assert!( + !matches!(&next, Some(Output::CancelConnection { id }) if *id == first_attempt), + "in-progress attempt must not be cancelled on CNAME mismatch; got {next:?}" + ); + + // Finish feeding remaining DNS results and let the machine run to completion. + he.process_input( + Input::DnsResult { + id: Id::from(2), + result: DnsResult::A(Ok(vec![ORIGIN_V4]), Some(CDN_B.into())), + }, + now, + ); + + // Fail the in-progress CDN_A attempt to let the machine advance. + he.process_input( + Input::ConnectionResult { + id: first_attempt, + result: ConnectionResult::Failure("fail".to_string()), + }, + now, + ); + + // Remaining attempts (driven by collect_attempts) must use origin addresses only. + let remaining = collect_attempts(&mut he, &mut now, None); + let cdn: HashSet = HashSet::from([CDN_A_V6.into(), CDN_A_V4.into()]); + assert!( + attempted_addrs(&remaining).is_disjoint(&cdn), + "no further attempts must target the CDN after the CNAME mismatch" + ); + assert!( + !any_ech(&remaining), + "subsequent attempts must not carry the dropped ECH record" + ); +} diff --git a/third_party/rust/happy-eyeballs/tests/network_config.rs b/third_party/rust/happy-eyeballs/tests/network_config.rs index 0b8907e5bc649..d59f581ca4e6e 100644 --- a/third_party/rust/happy-eyeballs/tests/network_config.rs +++ b/third_party/rust/happy-eyeballs/tests/network_config.rs @@ -54,11 +54,9 @@ fn alt_svc_used_immediately() { let mut he = HappyEyeballs::new_with_network_config(HOSTNAME, PORT, config).unwrap(); // Alt-svc with H3 should make H3 available even without HTTPS DNS response + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_negative(Id::from(0))), Some(out_resolution_delay()), @@ -93,11 +91,9 @@ fn alt_svc_with_port() { }; let (mut now, mut he) = setup_with_config(config); + expect_initial_dns_queries(&mut he, now); he.expect( vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), ( Some(in_dns_https_negative(Id::from(0))), Some(out_resolution_delay()), @@ -226,19 +222,15 @@ fn custom_delays() { ..NetworkConfig::default() }); + expect_initial_dns_queries(&mut he, now); he.expect( - vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), - ( - Some(in_dns_a_positive(Id::from(2))), - // Should use the custom resolution delay, not the default 50ms. - Some(Output::Timer { - duration: custom_resolution_delay, - }), - ), - ], + vec![( + Some(in_dns_a_positive(Id::from(2))), + // Should use the custom resolution delay, not the default 50ms. + Some(Output::Timer { + duration: custom_resolution_delay, + }), + )], now, ); diff --git a/third_party/rust/happy-eyeballs/tests/type_impls.rs b/third_party/rust/happy-eyeballs/tests/type_impls.rs index be30470097904..f3bb35f4b4242 100644 --- a/third_party/rust/happy-eyeballs/tests/type_impls.rs +++ b/third_party/rust/happy-eyeballs/tests/type_impls.rs @@ -76,16 +76,12 @@ fn happy_eyeballs_debug() { // Drive through DNS queries, feed HTTPS+ECH and AAAA to get a connection // attempt that carries ECH config. + expect_initial_dns_queries(&mut he2, now2); he2.expect( - vec![ - (None, Some(out_send_dns_https(Id::from(0)))), - (None, Some(out_send_dns_aaaa(Id::from(1)))), - (None, Some(out_send_dns_a(Id::from(2)))), - ( - Some(in_dns_https_positive_ech(Id::from(0))), - Some(out_resolution_delay()), - ), - ], + vec![( + Some(in_dns_https_positive_ech(Id::from(0))), + Some(out_resolution_delay()), + )], now2, );