/// Tests for HTTPS/SVCB DNS record handling including ECH, port SvcParams, /// multiple ServiceInfo records, and SVC1 target name resolution. mod common; use common::*;
use std::{
collections::HashSet,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
};
// 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.
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()),
),
],
now,
);
/// HTTPS RR address hints must be discarded when the corresponding address /// family returns a negative answer. Per the Happy Eyeballs v3 draft, hints /// apply only "when A and AAAA records are not available yet"; a negative /// answer replaces them. /// /// Tested for both preferences (prefer-V6 with AAAA negative, prefer-V4 with /// A negative) to verify symmetry. #[test] fn hints_discarded_on_negative_answer() { struct Case {
config: NetworkConfig, /// Non-preferred family, returns positive — arrives first.
first_arrives: Input, /// Preferred family, returns negative — arrives second.
second_arrives: Input,
ipv6_hints: Vec<Ipv6Addr>,
ipv4_hints: Vec<Ipv4Addr>,
attempt_1: Output,
attempt_2: Output,
attempt_3: Output, // origin fallback
}
let cases = vec![ // Prefer V6: AAAA negative, A positive — V6 hint must be discarded.
Case {
config: NetworkConfig::default(),
first_arrives: in_dns_a_positive(Id::from(2)),
second_arrives: in_dns_aaaa_negative(Id::from(1)),
ipv6_hints: vec![V6_ADDR],
ipv4_hints: vec![],
attempt_1: out_attempt_v4_h3(Id::from(3)),
attempt_2: out_attempt_v4_h2(Id::from(4)),
attempt_3: out_attempt_v4_h1_h2(Id::from(5)),
}, // Prefer V4: A negative, AAAA positive — V4 hint must be discarded.
Case {
config: NetworkConfig {
ip: IpPreference::DualStackPreferV4,
..NetworkConfig::default()
},
first_arrives: in_dns_aaaa_positive(Id::from(1)),
second_arrives: in_dns_a_negative(Id::from(2)),
ipv6_hints: vec![],
ipv4_hints: vec![V4_ADDR],
attempt_1: out_attempt_v6_h3(Id::from(3)),
attempt_2: out_attempt_v6_h2(Id::from(4)),
attempt_3: out_attempt_v6_h1_h2(Id::from(5)),
},
];
for case in cases { let (mut now, mut he) = setup_with_config(case.config);
/// When ECH is disabled in the network config, ECH configs from HTTPS records /// are ignored: endpoints get `ech_config: None` and the origin fallback is /// not skipped. /// /// HTTPS record has ECH + H3 ALPN with v6 hints. AAAA positive for origin. /// With ECH disabled: /// - HTTPS bucket uses hints: V6:H3 (no ECH) /// - Origin fallback is NOT skipped: V6:H2OrH1 /// /// <https://github.com/mozilla/happy-eyeballs/issues/20> #[test] fn ech_disabled() { let (mut now, mut he) = setup_with_config(NetworkConfig {
ech: false,
..NetworkConfig::default()
});
#[test] fn multiple_target_names() { 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)))), // HTTPS response with a different target name
(
Some(in_dns_https_positive_svc1(Id::from(0))),
Some(out_send_dns_svc1(Id::from(3))),
), // Now we have queries for both "example.com" and "svc1.example.com." // Getting a positive AAAA for the main host
(
Some(in_dns_aaaa_positive(Id::from(1))),
Some(Output::AttemptConnection {
id: Id::from(4),
endpoint: Endpoint {
address: SocketAddr::new(V6_ADDR_2.into(), PORT),
http_version: ConnectionAttemptHttpVersions::H3,
ech_config: None,
},
is_ech_retry: false,
}),
),
],
now,
);
}
/// Two HTTPS ServiceInfo records where only the first has ECH config ("partial ECH"). /// When any ServiceInfo has ECH, those without ECH are skipped. /// The origin fallback is also skipped. /// /// ```dns /// test.partial_ech.org HTTPS 1 svc1.example.com. alpn="h3" port=9443 ech="..." /// test.partial_ech.org HTTPS 2 svc2.example.com. alpn="h2" port=10443 /// ``` /// /// HOSTNAME resolves AAAA to V6_ADDR and A to V4_ADDR. /// SVC1 resolves A to V4_ADDR_2. SVC2 DNS is never queried (no ECH). /// /// Only the ECH-enabled ServiceInfo produces connection attempts: /// /// priority-1 bucket (SVC1, port 9443, ech): V4_2:H3, V4_2:H2 /// priority-2 bucket (SVC2, port 10443): skipped (no ECH, not even resolved) /// fallback bucket (HOSTNAME): skipped (no ECH) #[test] fn partial_ech_two_service_infos() { const SVC2: &str = "svc2.example.com."; const SVC1_PORT: u16 = 9443; const SVC2_PORT: u16 = 10443;
// Both SVC1 and SVC2 produce attempts (both have ECH). // Origin fallback is skipped — no ECH on the origin.
he.expect_connection_attempts(
&mut now,
vec![ // priority=1 (SVC1, port 9443, ech)
Output::AttemptConnection {
id: Id::from(8),
endpoint: Endpoint {
address: SocketAddr::new(V4_ADDR_2.into(), SVC1_PORT),
http_version: ConnectionAttemptHttpVersions::H2,
ech_config: Some(ech_config()),
},
is_ech_retry: false,
}, // priority=2 (SVC2, port 10443, ech)
Output::AttemptConnection {
id: Id::from(9),
endpoint: Endpoint {
address: SocketAddr::new(V4_ADDR.into(), SVC2_PORT),
http_version: ConnectionAttemptHttpVersions::H3,
ech_config: Some(ech_config()),
},
is_ech_retry: false,
},
Output::AttemptConnection {
id: Id::from(10),
endpoint: Endpoint {
address: SocketAddr::new(V4_ADDR.into(), SVC2_PORT),
http_version: ConnectionAttemptHttpVersions::H2,
ech_config: Some(ech_config()),
},
is_ech_retry: false,
},
],
);
}
/// Partial ECH with an alt-svc record on the origin. Both alt-svc and origin /// fallback are skipped because they carry no ECH config. /// /// ```dns /// example.com HTTPS 1 svc1.example.com. alpn="h3" port=9443 ech="..." /// example.com HTTPS 2 svc2.example.com. alpn="h2" port=10443 /// ``` /// Alt-svc: h3 on port 8443 /// /// HOSTNAME resolves AAAA to V6_ADDR and A to V4_ADDR. /// SVC1 resolves A to V4_ADDR_2. /// /// priority-1 bucket (SVC1, port 9443, ech): V4_2:H3, V4_2:H2 /// priority-2 bucket (SVC2, port 10443): skipped (no ECH, not resolved) /// alt-svc bucket (port 8443): skipped (no ECH) /// fallback bucket (HOSTNAME, port 443): skipped (no ECH) #[test] fn partial_ech_with_alt_svc() { const SVC2: &str = "svc2.example.com."; const SVC1_PORT: u16 = 9443; const SVC2_PORT: u16 = 10443; const ALT_SVC_PORT: u16 = 8443;
// Only SVC1 (with ECH). Alt-svc, SVC2, and fallback all skipped.
now += CONNECTION_ATTEMPT_DELAY;
he.expect(
vec![(
None,
Some(Output::AttemptConnection {
id: Id::from(6),
endpoint: Endpoint {
address: SocketAddr::new(V4_ADDR_2.into(), SVC1_PORT),
http_version: ConnectionAttemptHttpVersions::H2,
ech_config: Some(ech_config()),
},
is_ech_retry: false,
}),
)],
now,
);
now += CONNECTION_ATTEMPT_DELAY;
he.expect(vec![(None, None)], now);
}
mod https_port_svcparam_overrides_port_for { usesuper::*;
fn check(ipv4_hints: Vec<Ipv4Addr>) { let (mut now, mut he) = setup(); // constructed with PORT (443)
// 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.
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()),
),
],
now,
);
now += RESOLUTION_DELAY;
he.expect(
vec![(None, Some(out_attempt_v6_h3_custom_port(Id::from(3))))],
now,
);
}
#[test] fn v6_hints() {
check(vec![]);
}
/// HTTPS record with both IPv4 and IPv6 hints and a `port` SvcParam: both /// families use the overridden port. #[test] fn v4_and_v6_hints() {
check(vec![V4_ADDR]);
}
}
#[test] fn https_port_svcparam_applies_to_resolved_a_and_aaaa() { let (now, mut he) = setup(); // constructed with PORT (443)
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 {
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![],
ipv4_hints: vec![],
ech_config: None,
port: Some(CUSTOM_PORT),
}])),
}),
Some(out_resolution_delay()),
), // Positive AAAA: connection attempt must use port 8443, not 443
(
Some(in_dns_aaaa_positive(Id::from(1))),
Some(out_attempt_v6_h3_custom_port(Id::from(3))),
),
(
Some(in_dns_a_positive(Id::from(2))),
Some(out_connection_attempt_delay()),
), // Positive A: connection attempt must use port 8443, not 443
(
Some(in_connection_result_negative(Id::from(3))),
Some(out_attempt_v4_h3_custom_port(Id::from(4))),
),
],
now,
);
}
/// Website with HTTPS record with `noDefaultAlpn` set. /// /// See e.g. <adamwoodland.com>. #[test] fn no_default_alpn() { let (now, mut he) = setup();
/// HTTPS record redirects to a different target name (no IP hints). Addresses /// resolved for that target name are used in connection attempts, with higher /// priority than the origin fallback. /// /// ```dns /// example.com HTTPS 1 svc1.example.com. alpn="h3" /// svc1.example.com. AAAA 2001:db8::2 /// svc1.example.com. A 192.0.2.2 /// example.com AAAA 2001:db8::1 /// example.com A 192.0.2.1 /// ``` /// /// Expected connection attempts: /// SVC1 bucket (priority 1): V6_ADDR_2:H3, V4_ADDR_2:H3 /// fallback bucket (origin): V6:H2OrH1, V4:H2OrH1 /// /// <https://github.com/mozilla/happy-eyeballs/issues/10> #[test] fn target_name_redirect_addresses_used_in_connection_attempts() { let (mut now, mut he) = setup();
/// HTTPS record with `alpn="h3"` and `port=8443`. The HTTPS bucket should use /// H3 at port 8443, but the fallback bucket (origin domain, authority port) /// must use the default HTTP versions (H2OrH1), not H3 which came from the /// HTTPS record. /// /// ```dns /// example.com HTTPS 1 . alpn="h3" port=8443 /// example.com A 192.0.2.1 /// ``` /// /// Expected connection attempts: /// HTTPS bucket (port 8443): V4:H3 /// fallback bucket (port 443): V4:H2OrH1 #[test] fn https_fallback_uses_default_http_versions() { let (mut 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)))), // HTTPS record with port=8443, alpn=h3 only
(
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]),
ipv6_hints: vec![],
ipv4_hints: vec![],
ech_config: None,
port: Some(CUSTOM_PORT),
}])),
}),
Some(out_resolution_delay()),
),
(
Some(in_dns_aaaa_negative(Id::from(1))),
Some(out_resolution_delay()),
), // Positive A: connection attempt uses port 8443 with H3 from HTTPS record
(
Some(in_dns_a_positive(Id::from(2))),
Some(out_attempt_v4_h3_custom_port(Id::from(3))),
),
(None, Some(out_connection_attempt_delay())),
],
now,
);
// Fallback on port 443 must use default H2OrH1, NOT H3.
he.expect_connection_attempts(&mut now, vec![out_attempt_v4_h1_h2(Id::from(4))]);
}
/// When a connection attempt fails with `EchRetry`, the state machine should /// emit a new connection attempt to the same endpoint with the new ECH config. /// /// Setup: /// HTTPS record with ECH config, AAAA positive. /// First connection attempt uses original ECH config. /// Server rejects ECH and provides retry_configs. /// State machine emits a new attempt with updated ECH config. #[test] fn ech_retry_same_endpoint() { let (now, mut he) = setup();
let new_ech_config = EchConfig::new(vec![10, 20, 30, 40, 50]);
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::H2]),
ipv6_hints: vec![],
ipv4_hints: vec![],
ech_config: Some(ech_config()),
port: None,
}])),
}),
Some(out_resolution_delay()),
),
(
Some(in_dns_aaaa_positive(Id::from(1))), // First connection attempt with original ECH config.
Some(Output::AttemptConnection {
id: Id::from(3),
endpoint: Endpoint {
address: SocketAddr::new(V6_ADDR.into(), PORT),
http_version: ConnectionAttemptHttpVersions::H2,
ech_config: Some(ech_config()),
},
is_ech_retry: false,
}),
),
(None, Some(out_connection_attempt_delay())), // Server rejects ECH and provides retry_configs.
(
Some(Input::ConnectionResult {
id: Id::from(3),
result: ConnectionResult::EchRetry(new_ech_config.clone()),
}), // State machine emits a new attempt with the new ECH config // immediately (no delay — this is a server-initiated retry, // not a new candidate).
Some(Output::AttemptConnection {
id: Id::from(4),
endpoint: Endpoint {
address: SocketAddr::new(V6_ADDR.into(), PORT),
http_version: ConnectionAttemptHttpVersions::H2,
ech_config: Some(new_ech_config.clone()),
},
is_ech_retry: true,
}),
),
],
now,
);
}
/// `EchRetry` with an empty `EchConfig` models the SSL_ERROR_ECH_RETRY_WITHOUT_ECH /// path on the consumer side (server told us to retry *without* ECH). The state /// machine forwards the bytes verbatim, but the retry attempt must still be /// flagged `is_ech_retry: true` so consumers can label it. #[test] fn ech_retry_without_ech_sets_flag() { let (now, mut he) = setup();
/// Per RFC 9849 Section 6.1.6: /// /// > Clients SHOULD NOT accept "retry_config" in response to a connection /// > initiated in response to a "retry_config". /// /// The state machine must ignore `EchRetry` on an ECH-retried attempt and /// treat it as a plain failure, then fall through to remaining endpoints. #[test] fn ech_retry_no_infinite_loop() { let (now, mut he) = setup();
let retry_ech_config = EchConfig::new(vec![10, 20, 30, 40, 50]); let retry_ech_config_2 = EchConfig::new(vec![60, 70, 80]);
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.