feat(resolver): filter_aaaa for IPv4-only networks (#112)
When enabled, AAAA queries short-circuit to NODATA (NOERROR + empty answer) so Happy Eyeballs clients don't stall waiting on a v6 address they can't use. Also strips `ipv6hint` SvcParam from HTTPS/SVCB answers (RFC 9460) so Chrome ≥103, Firefox, and Safari don't bypass the AAAA filter via the HTTPS record path. Local data is preserved: overrides, zones, the .numa proxy, and the blocklist sinkhole keep whatever v6 addresses they configure — the filter only kicks in on the cache/forward/recursive path. NODATA is correct per RFC 2308 here; NXDOMAIN would incorrectly imply the name doesn't exist for A queries either. Off by default. Opt in via `filter_aaaa = true` under `[server]`.
This commit is contained in:
10
numa.toml
10
numa.toml
@@ -8,6 +8,16 @@ api_port = 5380
|
|||||||
# %PROGRAMDATA%\numa on windows. Override for
|
# %PROGRAMDATA%\numa on windows. Override for
|
||||||
# containerized deploys or tests that can't
|
# containerized deploys or tests that can't
|
||||||
# write to the system path.
|
# write to the system path.
|
||||||
|
# filter_aaaa = true # on IPv4-only networks, answer AAAA queries with
|
||||||
|
# NODATA (NOERROR + empty answer) so Happy Eyeballs
|
||||||
|
# clients don't wait on a v6 attempt that can't
|
||||||
|
# succeed. Also strips `ipv6hint` from HTTPS/SVCB
|
||||||
|
# records (RFC 9460) so modern browsers (Chrome
|
||||||
|
# ≥103, Firefox, Safari) don't bypass the AAAA
|
||||||
|
# filter via SVCB hints. Local zones, overrides,
|
||||||
|
# and the .numa proxy are NOT filtered — you can
|
||||||
|
# still configure v6 records for local services.
|
||||||
|
# Default: false.
|
||||||
|
|
||||||
# [upstream]
|
# [upstream]
|
||||||
# mode = "forward" # "forward" (default) — relay to upstream
|
# mode = "forward" # "forward" (default) — relay to upstream
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ pub struct ServerConfig {
|
|||||||
/// Defaults to `crate::data_dir()` (platform-specific system path) if unset.
|
/// Defaults to `crate::data_dir()` (platform-specific system path) if unset.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub data_dir: Option<PathBuf>,
|
pub data_dir: Option<PathBuf>,
|
||||||
|
/// Synthesize NODATA (NOERROR + empty answer) for AAAA queries, and
|
||||||
|
/// strip `ipv6hint` from HTTPS/SVCB responses (RFC 9460). For IPv4-only
|
||||||
|
/// networks where Happy Eyeballs fallback adds latency. Local zones,
|
||||||
|
/// overrides, and the service proxy are not affected. Default false.
|
||||||
|
#[serde(default)]
|
||||||
|
pub filter_aaaa: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerConfig {
|
impl Default for ServerConfig {
|
||||||
@@ -102,6 +108,7 @@ impl Default for ServerConfig {
|
|||||||
api_port: default_api_port(),
|
api_port: default_api_port(),
|
||||||
api_bind_addr: default_api_bind_addr(),
|
api_bind_addr: default_api_bind_addr(),
|
||||||
data_dir: None,
|
data_dir: None,
|
||||||
|
filter_aaaa: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,6 +587,17 @@ mod tests {
|
|||||||
assert!(config.lan.enabled);
|
assert!(config.lan.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_aaaa_defaults_false() {
|
||||||
|
assert!(!ServerConfig::default().filter_aaaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_aaaa_parses_from_server_section() {
|
||||||
|
let config: Config = toml::from_str("[server]\nfilter_aaaa = true").unwrap();
|
||||||
|
assert!(config.server.filter_aaaa);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn custom_bind_addrs_parse() {
|
fn custom_bind_addrs_parse() {
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
|
|||||||
155
src/ctx.rs
155
src/ctx.rs
@@ -77,6 +77,10 @@ pub struct ServerCtx {
|
|||||||
pub ca_pem: Option<String>,
|
pub ca_pem: Option<String>,
|
||||||
pub mobile_enabled: bool,
|
pub mobile_enabled: bool,
|
||||||
pub mobile_port: u16,
|
pub mobile_port: u16,
|
||||||
|
/// When true, AAAA queries short-circuit with NODATA (NOERROR + empty
|
||||||
|
/// answer) instead of hitting cache/forwarding/upstream. Local data
|
||||||
|
/// (overrides, zones, .numa proxy, blocklist sinkhole) is unaffected.
|
||||||
|
pub filter_aaaa: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,
|
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,
|
||||||
@@ -172,6 +176,13 @@ pub async fn resolve_query(
|
|||||||
60,
|
60,
|
||||||
));
|
));
|
||||||
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
|
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
|
||||||
|
} else if qtype == QueryType::AAAA && ctx.filter_aaaa {
|
||||||
|
// RFC 2308 NODATA: NOERROR with empty answer section. Prevents
|
||||||
|
// Happy Eyeballs clients from waiting on an AAAA they'll never use
|
||||||
|
// on IPv4-only networks. NXDOMAIN would be wrong (it'd imply the
|
||||||
|
// name doesn't exist for A either).
|
||||||
|
let resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||||
|
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||||
} else {
|
} else {
|
||||||
let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype);
|
let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype);
|
||||||
if let Some((cached, cached_dnssec, freshness)) = cached {
|
if let Some((cached, cached_dnssec, freshness)) = cached {
|
||||||
@@ -334,6 +345,13 @@ pub async fn resolve_query(
|
|||||||
strip_dnssec_records(&mut response);
|
strip_dnssec_records(&mut response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filter_aaaa: also strip ipv6hint from HTTPS/SVCB answers so modern
|
||||||
|
// browsers (Chrome ≥103 etc.) don't receive v6 address hints via the
|
||||||
|
// HTTPS record path that bypasses AAAA entirely.
|
||||||
|
if ctx.filter_aaaa {
|
||||||
|
strip_https_ipv6_hints(&mut response);
|
||||||
|
}
|
||||||
|
|
||||||
// Echo EDNS back if client sent it
|
// Echo EDNS back if client sent it
|
||||||
if query.edns.is_some() {
|
if query.edns.is_some() {
|
||||||
response.edns = Some(crate::packet::EdnsOpt {
|
response.edns = Some(crate::packet::EdnsOpt {
|
||||||
@@ -491,6 +509,29 @@ fn strip_dnssec_records(pkt: &mut DnsPacket) {
|
|||||||
pkt.resources.retain(|r| !is_dnssec_record(r));
|
pkt.resources.retain(|r| !is_dnssec_record(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HTTPS RR type code (RFC 9460). Numa stores HTTPS/SVCB records as
|
||||||
|
/// `DnsRecord::UNKNOWN { qtype: 65, .. }` since it doesn't have a
|
||||||
|
/// dedicated variant.
|
||||||
|
const HTTPS_TYPE: u16 = 65;
|
||||||
|
|
||||||
|
fn strip_https_ipv6_hints(pkt: &mut DnsPacket) {
|
||||||
|
let rewrite = |rec: &mut DnsRecord| {
|
||||||
|
if let DnsRecord::UNKNOWN {
|
||||||
|
qtype: HTTPS_TYPE,
|
||||||
|
data,
|
||||||
|
..
|
||||||
|
} = rec
|
||||||
|
{
|
||||||
|
if let Some(new_data) = crate::svcb::strip_ipv6hint(data) {
|
||||||
|
*data = new_data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pkt.answers.iter_mut().for_each(rewrite);
|
||||||
|
pkt.authorities.iter_mut().for_each(rewrite);
|
||||||
|
pkt.resources.iter_mut().for_each(rewrite);
|
||||||
|
}
|
||||||
|
|
||||||
fn is_special_use_domain(qname: &str) -> bool {
|
fn is_special_use_domain(qname: &str) -> bool {
|
||||||
if qname.ends_with(".in-addr.arpa") {
|
if qname.ends_with(".in-addr.arpa") {
|
||||||
// RFC 6303: private + loopback + link-local reverse DNS
|
// RFC 6303: private + loopback + link-local reverse DNS
|
||||||
@@ -1187,6 +1228,120 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pipeline_filter_aaaa_returns_nodata() {
|
||||||
|
let mut ctx = crate::testutil::test_ctx().await;
|
||||||
|
ctx.filter_aaaa = true;
|
||||||
|
let ctx = Arc::new(ctx);
|
||||||
|
|
||||||
|
let (resp, path) = resolve_in_test(&ctx, "example.com", QueryType::AAAA).await;
|
||||||
|
assert_eq!(path, QueryPath::Local);
|
||||||
|
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||||
|
assert!(resp.answers.is_empty(), "AAAA must be filtered to NODATA");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pipeline_filter_aaaa_leaves_a_queries_alone() {
|
||||||
|
let mut upstream_resp = DnsPacket::new();
|
||||||
|
upstream_resp.header.response = true;
|
||||||
|
upstream_resp.header.rescode = ResultCode::NOERROR;
|
||||||
|
upstream_resp.answers.push(DnsRecord::A {
|
||||||
|
domain: "example.com".to_string(),
|
||||||
|
addr: Ipv4Addr::new(93, 184, 216, 34),
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await;
|
||||||
|
|
||||||
|
let mut ctx = crate::testutil::test_ctx().await;
|
||||||
|
ctx.filter_aaaa = true;
|
||||||
|
ctx.upstream_pool
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.set_primary(vec![Upstream::Udp(upstream_addr)]);
|
||||||
|
let ctx = Arc::new(ctx);
|
||||||
|
|
||||||
|
let (resp, path) = resolve_in_test(&ctx, "example.com", QueryType::A).await;
|
||||||
|
assert_eq!(path, QueryPath::Upstream);
|
||||||
|
assert_eq!(resp.answers.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pipeline_filter_aaaa_respects_override() {
|
||||||
|
let mut ctx = crate::testutil::test_ctx().await;
|
||||||
|
ctx.filter_aaaa = true;
|
||||||
|
ctx.overrides
|
||||||
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.insert("v6.test", "2001:db8::1", 60, None)
|
||||||
|
.unwrap();
|
||||||
|
let ctx = Arc::new(ctx);
|
||||||
|
|
||||||
|
let (resp, path) = resolve_in_test(&ctx, "v6.test", QueryType::AAAA).await;
|
||||||
|
assert_eq!(path, QueryPath::Overridden);
|
||||||
|
assert_eq!(resp.answers.len(), 1, "override must win over filter");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pipeline_filter_aaaa_strips_ipv6hint_from_https() {
|
||||||
|
// Build an HTTPS record (type 65) with ipv6hint (key 6). Cache it,
|
||||||
|
// then query with filter_aaaa on — the returned rdata must have
|
||||||
|
// ipv6hint removed.
|
||||||
|
let mut rdata = Vec::new();
|
||||||
|
rdata.extend_from_slice(&1u16.to_be_bytes()); // priority
|
||||||
|
rdata.push(0); // empty target (".")
|
||||||
|
// alpn = ["h3"]
|
||||||
|
rdata.extend_from_slice(&1u16.to_be_bytes());
|
||||||
|
rdata.extend_from_slice(&3u16.to_be_bytes());
|
||||||
|
rdata.extend_from_slice(&[0x02, b'h', b'3']);
|
||||||
|
// ipv6hint = [2606:4700::1]
|
||||||
|
rdata.extend_from_slice(&6u16.to_be_bytes());
|
||||||
|
rdata.extend_from_slice(&16u16.to_be_bytes());
|
||||||
|
rdata.extend_from_slice(&[
|
||||||
|
0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let mut pkt = DnsPacket::new();
|
||||||
|
pkt.header.response = true;
|
||||||
|
pkt.header.rescode = ResultCode::NOERROR;
|
||||||
|
pkt.questions.push(crate::question::DnsQuestion {
|
||||||
|
name: "hints.test".to_string(),
|
||||||
|
qtype: QueryType::HTTPS,
|
||||||
|
});
|
||||||
|
pkt.answers.push(DnsRecord::UNKNOWN {
|
||||||
|
domain: "hints.test".to_string(),
|
||||||
|
qtype: 65,
|
||||||
|
data: rdata.clone(),
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut ctx = crate::testutil::test_ctx().await;
|
||||||
|
ctx.filter_aaaa = true;
|
||||||
|
ctx.cache
|
||||||
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.insert("hints.test", QueryType::HTTPS, &pkt);
|
||||||
|
let ctx = Arc::new(ctx);
|
||||||
|
|
||||||
|
let (resp, path) = resolve_in_test(&ctx, "hints.test", QueryType::HTTPS).await;
|
||||||
|
assert_eq!(path, QueryPath::Cached);
|
||||||
|
assert_eq!(resp.answers.len(), 1);
|
||||||
|
match &resp.answers[0] {
|
||||||
|
DnsRecord::UNKNOWN { data, .. } => {
|
||||||
|
assert!(
|
||||||
|
data.len() < rdata.len(),
|
||||||
|
"ipv6hint (20 bytes) must be removed"
|
||||||
|
);
|
||||||
|
// Bytes for key=6 must not appear at any 4-byte boundary in the
|
||||||
|
// params section — cheap structural check.
|
||||||
|
assert!(
|
||||||
|
!data.windows(4).any(|w| w == [0, 6, 0, 16]),
|
||||||
|
"ipv6hint TLV header must be absent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected UNKNOWN record, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pipeline_blocklist_sinkhole() {
|
async fn pipeline_blocklist_sinkhole() {
|
||||||
let ctx = crate::testutil::test_ctx().await;
|
let ctx = crate::testutil::test_ctx().await;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub mod service_store;
|
|||||||
pub mod setup_phone;
|
pub mod setup_phone;
|
||||||
pub mod srtt;
|
pub mod srtt;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
pub mod svcb;
|
||||||
pub mod system_dns;
|
pub mod system_dns;
|
||||||
pub mod tls;
|
pub mod tls;
|
||||||
pub mod wire;
|
pub mod wire;
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ pub async fn run(config_path: String) -> crate::Result<()> {
|
|||||||
ca_pem,
|
ca_pem,
|
||||||
mobile_enabled: config.mobile.enabled,
|
mobile_enabled: config.mobile.enabled,
|
||||||
mobile_port: config.mobile.port,
|
mobile_port: config.mobile.port,
|
||||||
|
filter_aaaa: config.server.filter_aaaa,
|
||||||
});
|
});
|
||||||
|
|
||||||
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
||||||
|
|||||||
177
src/svcb.rs
Normal file
177
src/svcb.rs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
//! Minimal SVCB/HTTPS (RFC 9460) RDATA parser — just enough to strip
|
||||||
|
//! the `ipv6hint` SvcParam. Used by the `filter_aaaa` feature so
|
||||||
|
//! HTTPS-record-aware clients (Chrome ≥103, Firefox, Safari) don't
|
||||||
|
//! receive v6 address hints on IPv4-only networks.
|
||||||
|
|
||||||
|
/// SvcParamKey = 6 (RFC 9460 §14.3.2).
|
||||||
|
const IPV6_HINT_KEY: u16 = 6;
|
||||||
|
|
||||||
|
/// Strip the `ipv6hint` SvcParam from an HTTPS/SVCB RDATA blob.
|
||||||
|
///
|
||||||
|
/// Returns `Some(new_rdata)` if `ipv6hint` was present and removed.
|
||||||
|
/// Returns `None` if the record had no `ipv6hint`, or if the RDATA
|
||||||
|
/// couldn't be parsed — in both cases the caller should keep the
|
||||||
|
/// original bytes untouched.
|
||||||
|
///
|
||||||
|
/// SVCB RDATA (RFC 9460 §2.2):
|
||||||
|
/// SvcPriority (u16)
|
||||||
|
/// TargetName (uncompressed DNS name — labels terminated by 0 octet)
|
||||||
|
/// SvcParams (series of {u16 key, u16 len, opaque[len] value}, sorted by key)
|
||||||
|
pub fn strip_ipv6hint(rdata: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
if rdata.len() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut pos = 2;
|
||||||
|
|
||||||
|
// TargetName — uncompressed per RFC 9460 §2.2
|
||||||
|
loop {
|
||||||
|
let len = *rdata.get(pos)? as usize;
|
||||||
|
pos += 1;
|
||||||
|
if len == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if len & 0xC0 != 0 {
|
||||||
|
// Pointer: forbidden in SVCB but defend against a broken upstream.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
pos = pos.checked_add(len)?;
|
||||||
|
if pos > rdata.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan params once to decide whether we need to rebuild.
|
||||||
|
let params_start = pos;
|
||||||
|
let mut scan = pos;
|
||||||
|
let mut has_ipv6hint = false;
|
||||||
|
while scan < rdata.len() {
|
||||||
|
if scan + 4 > rdata.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let key = u16::from_be_bytes([rdata[scan], rdata[scan + 1]]);
|
||||||
|
let vlen = u16::from_be_bytes([rdata[scan + 2], rdata[scan + 3]]) as usize;
|
||||||
|
let end = scan.checked_add(4)?.checked_add(vlen)?;
|
||||||
|
if end > rdata.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if key == IPV6_HINT_KEY {
|
||||||
|
has_ipv6hint = true;
|
||||||
|
}
|
||||||
|
scan = end;
|
||||||
|
}
|
||||||
|
if scan != rdata.len() || !has_ipv6hint {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild without ipv6hint, preserving param order (RFC 9460 requires
|
||||||
|
// ascending key order, which we preserve by filtering in place).
|
||||||
|
let mut out = Vec::with_capacity(rdata.len());
|
||||||
|
out.extend_from_slice(&rdata[..params_start]);
|
||||||
|
let mut pos = params_start;
|
||||||
|
while pos < rdata.len() {
|
||||||
|
let key = u16::from_be_bytes([rdata[pos], rdata[pos + 1]]);
|
||||||
|
let vlen = u16::from_be_bytes([rdata[pos + 2], rdata[pos + 3]]) as usize;
|
||||||
|
let end = pos + 4 + vlen;
|
||||||
|
if key != IPV6_HINT_KEY {
|
||||||
|
out.extend_from_slice(&rdata[pos..end]);
|
||||||
|
}
|
||||||
|
pos = end;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Build an SVCB RDATA blob from a priority, target labels, and
|
||||||
|
/// (key, value) param pairs. Used for constructing test vectors.
|
||||||
|
fn build(priority: u16, target: &[&str], params: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.extend_from_slice(&priority.to_be_bytes());
|
||||||
|
for label in target {
|
||||||
|
out.push(label.len() as u8);
|
||||||
|
out.extend_from_slice(label.as_bytes());
|
||||||
|
}
|
||||||
|
out.push(0);
|
||||||
|
for (key, value) in params {
|
||||||
|
out.extend_from_slice(&key.to_be_bytes());
|
||||||
|
out.extend_from_slice(&(value.len() as u16).to_be_bytes());
|
||||||
|
out.extend_from_slice(value);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alpn_h3() -> (u16, Vec<u8>) {
|
||||||
|
// alpn = ["h3"]: one length-prefixed ALPN id
|
||||||
|
(1, vec![0x02, b'h', b'3'])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ipv4hint_single() -> (u16, Vec<u8>) {
|
||||||
|
(4, vec![93, 184, 216, 34])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ipv6hint_single() -> (u16, Vec<u8>) {
|
||||||
|
// 2606:4700::1
|
||||||
|
(
|
||||||
|
6,
|
||||||
|
vec![
|
||||||
|
0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_ipv6hint_and_keeps_other_params() {
|
||||||
|
let rdata = build(1, &[], &[alpn_h3(), ipv4hint_single(), ipv6hint_single()]);
|
||||||
|
let stripped = strip_ipv6hint(&rdata).expect("ipv6hint present → stripped");
|
||||||
|
let expected = build(1, &[], &[alpn_h3(), ipv4hint_single()]);
|
||||||
|
assert_eq!(stripped, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_ipv6hint_returns_none() {
|
||||||
|
let rdata = build(1, &[], &[alpn_h3(), ipv4hint_single()]);
|
||||||
|
assert!(strip_ipv6hint(&rdata).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alias_mode_empty_params_returns_none() {
|
||||||
|
let rdata = build(0, &["example", "com"], &[]);
|
||||||
|
assert!(strip_ipv6hint(&rdata).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_ipv6hint_yields_empty_param_section() {
|
||||||
|
let rdata = build(1, &[], &[ipv6hint_single()]);
|
||||||
|
let stripped = strip_ipv6hint(&rdata).expect("ipv6hint present → stripped");
|
||||||
|
let expected = build(1, &[], &[]);
|
||||||
|
assert_eq!(stripped, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_target_name() {
|
||||||
|
let rdata = build(1, &["svc", "example", "net"], &[ipv6hint_single()]);
|
||||||
|
let stripped = strip_ipv6hint(&rdata).unwrap();
|
||||||
|
assert!(stripped.starts_with(&[0x00, 0x01])); // priority
|
||||||
|
assert_eq!(&stripped[2..6], b"\x03svc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_rdata_returns_none() {
|
||||||
|
// Priority only, no target terminator.
|
||||||
|
assert!(strip_ipv6hint(&[0, 1, 3, b'c', b'o', b'm']).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_input_returns_none() {
|
||||||
|
assert!(strip_ipv6hint(&[]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn param_length_overflow_returns_none() {
|
||||||
|
// key=6, length=0xFFFF but value is short — malformed.
|
||||||
|
let rdata = vec![0, 1, 0, 0, 6, 0xFF, 0xFF, 0, 1, 2];
|
||||||
|
assert!(strip_ipv6hint(&rdata).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ pub async fn test_ctx() -> ServerCtx {
|
|||||||
ca_pem: None,
|
ca_pem: None,
|
||||||
mobile_enabled: false,
|
mobile_enabled: false,
|
||||||
mobile_port: 8765,
|
mobile_port: 8765,
|
||||||
|
filter_aaaa: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user