From be98a02e493cf2736197158cc4712e699819fa69 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 19:52:06 +0300 Subject: [PATCH 1/7] feat(resolver): filter_aaaa for IPv4-only networks (#112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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]`. --- numa.toml | 10 +++ src/config.rs | 18 +++++ src/ctx.rs | 155 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/serve.rs | 1 + src/svcb.rs | 177 ++++++++++++++++++++++++++++++++++++++++++++++++ src/testutil.rs | 1 + 7 files changed, 363 insertions(+) create mode 100644 src/svcb.rs diff --git a/numa.toml b/numa.toml index ebb9720..c25654a 100644 --- a/numa.toml +++ b/numa.toml @@ -8,6 +8,16 @@ api_port = 5380 # %PROGRAMDATA%\numa on windows. Override for # containerized deploys or tests that can't # 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] # mode = "forward" # "forward" (default) — relay to upstream diff --git a/src/config.rs b/src/config.rs index 90d1ba3..309344b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -93,6 +93,12 @@ pub struct ServerConfig { /// Defaults to `crate::data_dir()` (platform-specific system path) if unset. #[serde(default)] pub data_dir: Option, + /// 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 { @@ -102,6 +108,7 @@ impl Default for ServerConfig { api_port: default_api_port(), api_bind_addr: default_api_bind_addr(), data_dir: None, + filter_aaaa: false, } } } @@ -580,6 +587,17 @@ mod tests { 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] fn custom_bind_addrs_parse() { let toml = r#" diff --git a/src/ctx.rs b/src/ctx.rs index 3a3a58a..b3f7ae2 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -77,6 +77,10 @@ pub struct ServerCtx { pub ca_pem: Option, pub mobile_enabled: bool, 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, @@ -172,6 +176,13 @@ pub async fn resolve_query( 60, )); (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 { let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype); if let Some((cached, cached_dnssec, freshness)) = cached { @@ -334,6 +345,13 @@ pub async fn resolve_query( 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 if query.edns.is_some() { 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)); } +/// 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 { if qname.ends_with(".in-addr.arpa") { // 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] async fn pipeline_blocklist_sinkhole() { let ctx = crate::testutil::test_ctx().await; diff --git a/src/lib.rs b/src/lib.rs index a16568b..bce8833 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ pub mod service_store; pub mod setup_phone; pub mod srtt; pub mod stats; +pub mod svcb; pub mod system_dns; pub mod tls; pub mod wire; diff --git a/src/serve.rs b/src/serve.rs index 1a9a764..8e85b32 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -236,6 +236,7 @@ pub async fn run(config_path: String) -> crate::Result<()> { ca_pem, mobile_enabled: config.mobile.enabled, mobile_port: config.mobile.port, + filter_aaaa: config.server.filter_aaaa, }); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); diff --git a/src/svcb.rs b/src/svcb.rs new file mode 100644 index 0000000..2228443 --- /dev/null +++ b/src/svcb.rs @@ -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> { + 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)]) -> Vec { + 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) { + // alpn = ["h3"]: one length-prefixed ALPN id + (1, vec![0x02, b'h', b'3']) + } + + fn ipv4hint_single() -> (u16, Vec) { + (4, vec![93, 184, 216, 34]) + } + + fn ipv6hint_single() -> (u16, Vec) { + // 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()); + } +} diff --git a/src/testutil.rs b/src/testutil.rs index 8687625..fab861b 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -63,6 +63,7 @@ pub async fn test_ctx() -> ServerCtx { ca_pem: None, mobile_enabled: false, mobile_port: 8765, + filter_aaaa: false, } } From 8014ebac9e9fdbf32e693c5f94ccca940177b64f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 05:52:29 +0300 Subject: [PATCH 2/7] test(integration): add Suite 7 for filter_aaaa + SUITES env filter Suite 7 exercises the full pipeline end-to-end: A resolves, AAAA returns NODATA, local [[zones]] AAAA bypasses the filter, and HTTPS ipv6hint is stripped from a real cloudflare.com response. A second config run with the flag unset guards against network-failure false-positives. SUITES=N (comma list) runs a subset, e.g. `SUITES=7 bash tests/integration.sh` skips suites 1-6 for fast iteration. --- tests/integration.sh | 158 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 1 deletion(-) diff --git a/tests/integration.sh b/tests/integration.sh index c70ec59..81bd28d 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash # Integration test suite for Numa # Runs a test instance on port 5354, validates all features, exits with status. -# Usage: ./tests/integration.sh [release|debug] +# Usage: +# ./tests/integration.sh [release|debug] # all suites +# SUITES=7 ./tests/integration.sh # only Suite 7 +# SUITES=1,3,7 ./tests/integration.sh # Suites 1, 3, and 7 set -euo pipefail @@ -14,6 +17,14 @@ LOG="/tmp/numa-integration-test.log" PASSED=0 FAILED=0 +# Suite filter: empty runs all; comma list runs a subset. +SUITES="${SUITES:-}" +should_run_suite() { + [ -z "$SUITES" ] && return 0 + case ",$SUITES," in *",$1,"*) return 0;; esac + return 1 +} + # Colors GREEN="\033[32m" RED="\033[31m" @@ -166,6 +177,7 @@ CONF } # ---- Suite 1: Recursive mode + DNSSEC ---- +if should_run_suite 1; then echo "" echo "╔══════════════════════════════════════════╗" echo "║ Suite 1: Recursive + DNSSEC + Blocking ║" @@ -234,7 +246,10 @@ kill "$NUMA_PID" 2>/dev/null || true wait "$NUMA_PID" 2>/dev/null || true sleep 1 +fi # end Suite 1 + # ---- Suite 2: Forward mode (backward compat) ---- +if should_run_suite 2; then echo "" echo "╔══════════════════════════════════════════╗" echo "║ Suite 2: Forward (DoH) + Blocking ║" @@ -261,7 +276,10 @@ enabled = true enabled = false " +fi # end Suite 2 + # ---- Suite 3: Forward UDP (plain, no DoH) ---- +if should_run_suite 3; then echo "" echo "╔══════════════════════════════════════════╗" echo "║ Suite 3: Forward (UDP) + No Blocking ║" @@ -307,7 +325,10 @@ kill "$NUMA_PID" 2>/dev/null || true wait "$NUMA_PID" 2>/dev/null || true sleep 1 +fi # end Suite 3 + # ---- Suite 4: Local zones + Overrides API ---- +if should_run_suite 4; then echo "" echo "╔══════════════════════════════════════════╗" echo "║ Suite 4: Local Zones + Overrides API ║" @@ -416,7 +437,10 @@ kill "$NUMA_PID" 2>/dev/null || true wait "$NUMA_PID" 2>/dev/null || true sleep 1 +fi # end Suite 4 + # ---- Suite 5: DNS-over-TLS (RFC 7858) ---- +if should_run_suite 5; then echo "" echo "╔══════════════════════════════════════════╗" echo "║ Suite 5: DNS-over-TLS (RFC 7858) ║" @@ -538,7 +562,10 @@ CONF fi sleep 1 +fi # end Suite 5 + # ---- Suite 6: Proxy + DoT coexistence ---- +if should_run_suite 6; then echo "" echo "╔══════════════════════════════════════════╗" echo "║ Suite 6: Proxy + DoT Coexistence ║" @@ -698,6 +725,135 @@ CONF rm -rf "$NUMA_DATA" fi +fi # end Suite 6 + +# ---- Suite 7: filter_aaaa (IPv4-only networks) ---- +if should_run_suite 7; then +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Suite 7: filter_aaaa ║" +echo "╚══════════════════════════════════════════╝" + +# Config A — filter on, with a local AAAA zone to prove local data bypass. +cat > "$CONFIG" << 'CONF' +[server] +bind_addr = "127.0.0.1:5354" +api_port = 5381 +filter_aaaa = true + +[upstream] +mode = "forward" +address = "9.9.9.9" +port = 53 + +[cache] +max_entries = 10000 + +[blocking] +enabled = false + +[proxy] +enabled = false + +[[zones]] +domain = "v6.test" +record_type = "AAAA" +value = "2001:db8::1" +ttl = 60 +CONF + +RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & +NUMA_PID=$! +sleep 3 + +DIG="dig @127.0.0.1 -p $PORT +time=5 +tries=1" + +echo "" +echo "=== filter_aaaa = true ===" + +# A queries must be untouched. +check "A record resolves under filter_aaaa" \ + "." \ + "$($DIG google.com A +short | head -1)" + +# AAAA must be NOERROR (NODATA), not NXDOMAIN, not SERVFAIL. +check "AAAA returns NOERROR (not NXDOMAIN)" \ + "status: NOERROR" \ + "$($DIG google.com AAAA 2>&1 | grep 'status:')" + +check "AAAA returns zero answers (NODATA shape)" \ + "ANSWER: 0" \ + "$($DIG google.com AAAA 2>&1 | grep -oE 'ANSWER: [0-9]+' | head -1)" + +# Local zone AAAA must survive the filter (PR claim: local data bypasses). +check "Local [[zones]] AAAA bypasses filter" \ + "2001:db8::1" \ + "$($DIG v6.test AAAA +short)" + +# HTTPS RR: ipv6hint (SvcParamKey 6) must be stripped. Query as `type65` +# because dig 9.10.6 (macOS) misparses `HTTPS` as a domain name; `type65` +# works on both 9.10.6 and 9.18. Assert on the raw rdata hex (RFC 3597 +# generic format), since dig 9.10.6 doesn't pretty-print HTTPS params. +# cloudflare.com's ipv6hint values sit under the 2606:4700 prefix — +# checking that `26064700` is absent from the rdata hex is a precise, +# upstream-stable signal that the TLV was stripped. +HTTPS_OUT=$($DIG cloudflare.com type65 2>&1) +if echo "$HTTPS_OUT" | grep -qE "cloudflare\.com\..*IN[[:space:]]+TYPE65"; then + HTTPS_HEX=$(echo "$HTTPS_OUT" | grep -A5 "IN[[:space:]]*TYPE65" | tr -d " \t\n") + if echo "$HTTPS_HEX" | grep -qi "26064700"; then + check "HTTPS ipv6hint stripped (2606:4700 absent from rdata)" "absent" "present" + else + check "HTTPS ipv6hint stripped (2606:4700 absent from rdata)" "absent" "absent" + fi +else + # Upstream didn't return an HTTPS record — skip rather than false-pass. + printf " ${DIM}~ HTTPS ipv6hint stripped (skipped: no HTTPS RR returned by upstream)${RESET}\n" +fi + +kill "$NUMA_PID" 2>/dev/null || true +wait "$NUMA_PID" 2>/dev/null || true +sleep 1 + +# Config B — filter off. Regression guard: prove AAAA answers come back +# when the flag isn't set, so a network failure in Config A can't silently +# pass as "filter working". +cat > "$CONFIG" << 'CONF' +[server] +bind_addr = "127.0.0.1:5354" +api_port = 5381 + +[upstream] +mode = "forward" +address = "9.9.9.9" +port = 53 + +[cache] +max_entries = 10000 + +[blocking] +enabled = false + +[proxy] +enabled = false +CONF + +RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & +NUMA_PID=$! +sleep 3 + +echo "" +echo "=== filter_aaaa unset (regression guard) ===" + +check "AAAA returns real answers with filter off" \ + ":" \ + "$($DIG google.com AAAA +short | head -1)" + +kill "$NUMA_PID" 2>/dev/null || true +wait "$NUMA_PID" 2>/dev/null || true +sleep 1 + +fi # end Suite 7 + # Summary echo "" TOTAL=$((PASSED + FAILED)) From 22dd3cd2222f7d19994125f61800c5eb3af672b5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 05:52:37 +0300 Subject: [PATCH 3/7] fix(resolver): skip ipv6hint strip for DO-bit clients Modifying HTTPS rdata invalidates any accompanying RRSIG, so a DNSSEC- validating downstream would reject the response as Bogus. Gate the strip on !client_do, matching the existing DNSSEC-records strip. Adds a regression test that catches the gate being removed: builds a query with EDNS DO=1, asserts the HTTPS rdata round-trips untouched. --- src/ctx.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index b3f7ae2..0b7dd80 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -347,8 +347,10 @@ pub async fn resolve_query( // 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 { + // HTTPS record path that bypasses AAAA entirely. Gated on !client_do + // because modifying rdata invalidates any accompanying RRSIG — a DO-bit + // validator downstream would reject the response as Bogus. + if ctx.filter_aaaa && !client_do { strip_https_ipv6_hints(&mut response); } @@ -1342,6 +1344,71 @@ mod tests { } } + #[tokio::test] + async fn pipeline_filter_aaaa_preserves_ipv6hint_for_dnssec_clients() { + // Regression guard for the DO-bit gate in resolve_query: modifying + // HTTPS rdata invalidates any accompanying RRSIG, so a DO=1 client + // must receive the record untouched even when filter_aaaa is on. + let mut rdata = Vec::new(); + rdata.extend_from_slice(&1u16.to_be_bytes()); + rdata.push(0); + 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); + + // Build a query with EDNS DO bit set — can't use resolve_in_test + // because it constructs a plain query without EDNS. + let mut query = DnsPacket::query(0xBEEF, "hints.test", QueryType::HTTPS); + query.edns = Some(crate::packet::EdnsOpt { + do_bit: true, + ..Default::default() + }); + let mut buf = BytePacketBuffer::new(); + query.write(&mut buf).unwrap(); + let raw = &buf.buf[..buf.pos]; + let src: SocketAddr = "127.0.0.1:1234".parse().unwrap(); + + let (resp_buf, _) = resolve_query(query, raw, src, &ctx, Transport::Udp) + .await + .unwrap(); + let mut resp_parse_buf = BytePacketBuffer::from_bytes(resp_buf.filled()); + let resp = DnsPacket::from_buffer(&mut resp_parse_buf).unwrap(); + + match &resp.answers[0] { + DnsRecord::UNKNOWN { data, .. } => { + assert_eq!( + data, &rdata, + "ipv6hint must be preserved for DO-bit clients" + ); + } + other => panic!("expected UNKNOWN record, got {:?}", other), + } + } + #[tokio::test] async fn pipeline_blocklist_sinkhole() { let ctx = crate::testutil::test_ctx().await; From 61ea2e510d5a5f7b4c9e375de375c04073512abd Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 05:58:47 +0300 Subject: [PATCH 4/7] refactor: dedupe HTTPS_TYPE, record-walk, and test rdata builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop `const HTTPS_TYPE: u16 = 65;` in favor of `QueryType::HTTPS.to_num()` at the single call site — avoids a fresh magic number alongside the existing enum mapping in question.rs. - Add `DnsPacket::for_each_record_mut` so `strip_https_ipv6_hints` stops hand-rolling the answers/authorities/resources walk; future section rewrites go through the same helper. - Promote the SVCB test-rdata builder from `svcb::tests` to module scope as `pub(crate) #[cfg(test)] fn build_rdata`, and reuse it in the two pipeline tests in ctx.rs — kills ~20 lines of byte-fiddling and keeps one RDATA-construction code path. --- src/ctx.rs | 70 +++++++++++++++++++++------------------------------ src/packet.rs | 8 ++++++ src/svcb.rs | 56 +++++++++++++++++++++++------------------ 3 files changed, 68 insertions(+), 66 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 0b7dd80..0dcef51 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -511,27 +511,17 @@ fn strip_dnssec_records(pkt: &mut DnsPacket) { 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; + let https_qtype = QueryType::HTTPS.to_num(); + pkt.for_each_record_mut(|rec| { + if let DnsRecord::UNKNOWN { qtype, data, .. } = rec { + if *qtype == https_qtype { + 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 { @@ -1285,22 +1275,20 @@ mod tests { #[tokio::test] async fn pipeline_filter_aaaa_strips_ipv6hint_from_https() { - // Build an HTTPS record (type 65) with ipv6hint (key 6). Cache it, + // Build an HTTPS record (type 65) with alpn + ipv6hint, 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, - ]); + // ipv6hint (20 bytes) removed. + let rdata = crate::svcb::build_rdata( + 1, + &[], + &[ + (1, vec![0x02, b'h', b'3']), + ( + 6, + vec![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; @@ -1349,14 +1337,14 @@ mod tests { // Regression guard for the DO-bit gate in resolve_query: modifying // HTTPS rdata invalidates any accompanying RRSIG, so a DO=1 client // must receive the record untouched even when filter_aaaa is on. - let mut rdata = Vec::new(); - rdata.extend_from_slice(&1u16.to_be_bytes()); - rdata.push(0); - 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 rdata = crate::svcb::build_rdata( + 1, + &[], + &[( + 6, + vec![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; diff --git a/src/packet.rs b/src/packet.rs index ba9e30a..a621c13 100644 --- a/src/packet.rs +++ b/src/packet.rs @@ -85,6 +85,14 @@ impl DnsPacket { + self.edns.as_ref().map_or(0, |e| e.options.capacity()) } + /// Apply `f` to every record in the three RR sections (answers, + /// authorities, resources). Does not touch questions or edns. + pub fn for_each_record_mut(&mut self, mut f: impl FnMut(&mut DnsRecord)) { + self.answers.iter_mut().for_each(&mut f); + self.authorities.iter_mut().for_each(&mut f); + self.resources.iter_mut().for_each(&mut f); + } + pub fn response_from(query: &DnsPacket, rescode: crate::header::ResultCode) -> DnsPacket { let mut resp = DnsPacket::new(); resp.header.id = query.header.id; diff --git a/src/svcb.rs b/src/svcb.rs index 2228443..444b063 100644 --- a/src/svcb.rs +++ b/src/svcb.rs @@ -80,28 +80,34 @@ pub fn strip_ipv6hint(rdata: &[u8]) -> Option> { Some(out) } +/// Build an SVCB RDATA blob from a priority, target labels, and +/// (key, value) param pairs. Shared by `svcb` unit tests and `ctx` +/// pipeline tests that need to seed the cache with a synthetic HTTPS RR. +#[cfg(test)] +pub(crate) fn build_rdata( + priority: u16, + target: &[&str], + params: &[(u16, Vec)], +) -> Vec { + 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 +} + #[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)]) -> Vec { - 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) { // alpn = ["h3"]: one length-prefixed ALPN id (1, vec![0x02, b'h', b'3']) @@ -123,35 +129,35 @@ mod tests { #[test] fn strips_ipv6hint_and_keeps_other_params() { - let rdata = build(1, &[], &[alpn_h3(), ipv4hint_single(), ipv6hint_single()]); + let rdata = build_rdata(1, &[], &[alpn_h3(), ipv4hint_single(), ipv6hint_single()]); let stripped = strip_ipv6hint(&rdata).expect("ipv6hint present → stripped"); - let expected = build(1, &[], &[alpn_h3(), ipv4hint_single()]); + let expected = build_rdata(1, &[], &[alpn_h3(), ipv4hint_single()]); assert_eq!(stripped, expected); } #[test] fn no_ipv6hint_returns_none() { - let rdata = build(1, &[], &[alpn_h3(), ipv4hint_single()]); + let rdata = build_rdata(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"], &[]); + let rdata = build_rdata(0, &["example", "com"], &[]); assert!(strip_ipv6hint(&rdata).is_none()); } #[test] fn only_ipv6hint_yields_empty_param_section() { - let rdata = build(1, &[], &[ipv6hint_single()]); + let rdata = build_rdata(1, &[], &[ipv6hint_single()]); let stripped = strip_ipv6hint(&rdata).expect("ipv6hint present → stripped"); - let expected = build(1, &[], &[]); + let expected = build_rdata(1, &[], &[]); assert_eq!(stripped, expected); } #[test] fn preserves_target_name() { - let rdata = build(1, &["svc", "example", "net"], &[ipv6hint_single()]); + let rdata = build_rdata(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"); From d6bb9a0f01f778f22bc03f7305a03377ea0abf24 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 06:24:54 +0300 Subject: [PATCH 5/7] fmt: rustfmt vec literal wrapping + signature collapse --- src/ctx.rs | 8 ++++++-- src/svcb.rs | 6 +----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 0dcef51..eeeb71f 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1285,7 +1285,9 @@ mod tests { (1, vec![0x02, b'h', b'3']), ( 6, - vec![0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01], + vec![ + 0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01, + ], ), ], ); @@ -1342,7 +1344,9 @@ mod tests { &[], &[( 6, - vec![0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01], + vec![ + 0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01, + ], )], ); diff --git a/src/svcb.rs b/src/svcb.rs index 444b063..ef65d04 100644 --- a/src/svcb.rs +++ b/src/svcb.rs @@ -84,11 +84,7 @@ pub fn strip_ipv6hint(rdata: &[u8]) -> Option> { /// (key, value) param pairs. Shared by `svcb` unit tests and `ctx` /// pipeline tests that need to seed the cache with a synthetic HTTPS RR. #[cfg(test)] -pub(crate) fn build_rdata( - priority: u16, - target: &[&str], - params: &[(u16, Vec)], -) -> Vec { +pub(crate) fn build_rdata(priority: u16, target: &[&str], params: &[(u16, Vec)]) -> Vec { let mut out = Vec::new(); out.extend_from_slice(&priority.to_be_bytes()); for label in target { From 5e85b147b97826314451af83f65161e40375830d Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 06:52:30 +0300 Subject: [PATCH 6/7] feat(resolver): apply ipv6hint strip to SVCB (type 64) too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTPS (65) and SVCB (64) share the RDATA wire format, so the existing parser already handles both — only the call site was HTTPS-only. Widen the qtype check and extend the existing pipeline test with a second query for SVCB. --- src/ctx.rs | 72 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index eeeb71f..0ba33c8 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -351,7 +351,7 @@ pub async fn resolve_query( // because modifying rdata invalidates any accompanying RRSIG — a DO-bit // validator downstream would reject the response as Bogus. if ctx.filter_aaaa && !client_do { - strip_https_ipv6_hints(&mut response); + strip_svcb_ipv6_hints(&mut response); } // Echo EDNS back if client sent it @@ -511,11 +511,16 @@ fn strip_dnssec_records(pkt: &mut DnsPacket) { pkt.resources.retain(|r| !is_dnssec_record(r)); } -fn strip_https_ipv6_hints(pkt: &mut DnsPacket) { +/// SVCB and HTTPS share the same RDATA wire format (RFC 9460), so the +/// ipv6hint strip applies to both. SVCB has no `QueryType` variant — it +/// arrives as `UNKNOWN { qtype: 64, .. }`. +const SVCB_QTYPE: u16 = 64; + +fn strip_svcb_ipv6_hints(pkt: &mut DnsPacket) { let https_qtype = QueryType::HTTPS.to_num(); pkt.for_each_record_mut(|rec| { if let DnsRecord::UNKNOWN { qtype, data, .. } = rec { - if *qtype == https_qtype { + if *qtype == https_qtype || *qtype == SVCB_QTYPE { if let Some(new_data) = crate::svcb::strip_ipv6hint(data) { *data = new_data; } @@ -1274,10 +1279,12 @@ mod tests { } #[tokio::test] - async fn pipeline_filter_aaaa_strips_ipv6hint_from_https() { - // Build an HTTPS record (type 65) with alpn + ipv6hint, cache it, - // then query with filter_aaaa on — the returned rdata must have - // ipv6hint (20 bytes) removed. + async fn pipeline_filter_aaaa_strips_ipv6hint_from_https_and_svcb() { + // HTTPS (type 65) and SVCB (type 64) share the same RDATA wire + // format (RFC 9460); the filter must strip ipv6hint from both. + // Build one HTTPS record with alpn + ipv6hint, then re-key it as + // SVCB and assert the returned rdata has the 20-byte hint removed + // in both cases. let rdata = crate::svcb::build_rdata( 1, &[], @@ -1306,31 +1313,50 @@ mod tests { ttl: 300, }); + // Seed an SVCB record (type 64) under a different name — same wire + // format as HTTPS, must get the same treatment. + let mut svcb_pkt = pkt.clone(); + svcb_pkt.questions[0].name = "svc.test".to_string(); + svcb_pkt.questions[0].qtype = QueryType::UNKNOWN(64); + if let DnsRecord::UNKNOWN { domain, qtype, .. } = &mut svcb_pkt.answers[0] { + *domain = "svc.test".to_string(); + *qtype = 64; + } + let mut ctx = crate::testutil::test_ctx().await; ctx.filter_aaaa = true; ctx.cache .write() .unwrap() .insert("hints.test", QueryType::HTTPS, &pkt); + ctx.cache + .write() + .unwrap() + .insert("svc.test", QueryType::UNKNOWN(64), &svcb_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" - ); + for (name, qtype, label) in [ + ("hints.test", QueryType::HTTPS, "HTTPS"), + ("svc.test", QueryType::UNKNOWN(64), "SVCB"), + ] { + let (resp, path) = resolve_in_test(&ctx, name, qtype).await; + assert_eq!(path, QueryPath::Cached, "{label}"); + assert_eq!(resp.answers.len(), 1, "{label}"); + match &resp.answers[0] { + DnsRecord::UNKNOWN { data, .. } => { + assert!( + data.len() < rdata.len(), + "{label}: 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]), + "{label}: ipv6hint TLV header must be absent" + ); + } + other => panic!("{label}: expected UNKNOWN record, got {other:?}"), } - other => panic!("expected UNKNOWN record, got {:?}", other), } } From f9e996ae78c644d6bda63341070107601b6d78fa Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 06:53:47 +0300 Subject: [PATCH 7/7] fmt: drop redundant comments per house style --- src/ctx.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 0ba33c8..23b1014 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -511,9 +511,6 @@ fn strip_dnssec_records(pkt: &mut DnsPacket) { pkt.resources.retain(|r| !is_dnssec_record(r)); } -/// SVCB and HTTPS share the same RDATA wire format (RFC 9460), so the -/// ipv6hint strip applies to both. SVCB has no `QueryType` variant — it -/// arrives as `UNKNOWN { qtype: 64, .. }`. const SVCB_QTYPE: u16 = 64; fn strip_svcb_ipv6_hints(pkt: &mut DnsPacket) { @@ -1280,11 +1277,6 @@ mod tests { #[tokio::test] async fn pipeline_filter_aaaa_strips_ipv6hint_from_https_and_svcb() { - // HTTPS (type 65) and SVCB (type 64) share the same RDATA wire - // format (RFC 9460); the filter must strip ipv6hint from both. - // Build one HTTPS record with alpn + ipv6hint, then re-key it as - // SVCB and assert the returned rdata has the 20-byte hint removed - // in both cases. let rdata = crate::svcb::build_rdata( 1, &[], @@ -1313,8 +1305,6 @@ mod tests { ttl: 300, }); - // Seed an SVCB record (type 64) under a different name — same wire - // format as HTTPS, must get the same treatment. let mut svcb_pkt = pkt.clone(); svcb_pkt.questions[0].name = "svc.test".to_string(); svcb_pkt.questions[0].qtype = QueryType::UNKNOWN(64);