From 34935baa351a24ca186276d721323aef7e684ab7 Mon Sep 17 00:00:00 2001 From: Chet Nichols III Date: Thu, 18 Jun 2026 00:08:20 -0700 Subject: [PATCH] feat(dns): parse reverse-DNS (PTR) query names into IP addresses Reverse DNS resolves a PTR query name -- an address in `in-addr.arpa` / `ip6.arpa` form -- back to a hostname. The lookup direction, arpa name to address, is a clean inverse, so this does it in Rust rather than a large PL/pgSQL function: `arpa_qname_to_ip` reverses the four octets (IPv4) or the 32 nibbles (IPv6) and hands back an `IpAddr`. The PTR handler will use that to look the interface up by address directly -- an indexed equality, not a per-row arpa string computed across a view. `Ipv6Addr` does the address assembly, so the IPv6 case is a few lines instead of the manual hextet expansion the equivalent SQL needs. This is the conversion foundation for reverse DNS; the indexed address lookup (`find_ptr_record`) and the handler arm build on it. Tests cover the IPv4 and IPv6 forms plus rejection of non-arpa and malformed names. This supports https://github.com/NVIDIA/infra-controller/issues/2637. Signed-off-by: Chet Nichols III --- crates/api-db/src/dns/mod.rs | 76 ++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/api-db/src/dns/mod.rs b/crates/api-db/src/dns/mod.rs index 3a2ddfed7b..e4a06e2837 100644 --- a/crates/api-db/src/dns/mod.rs +++ b/crates/api-db/src/dns/mod.rs @@ -19,12 +19,56 @@ pub mod domain; pub mod domain_metadata; pub mod resource_record; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + pub fn normalize_domain(name: &str) -> String { let normalize_domain = name.trim_end_matches('.').to_lowercase(); tracing::debug!("Normalized domain name: {} to: {}", name, normalize_domain); normalize_domain } +/// Parse a reverse-DNS (PTR) query name into the address it points at -- the +/// inverse of the `in-addr.arpa` (IPv4) / `ip6.arpa` (IPv6) form. Returns `None` +/// for anything that is not a well-formed arpa name, so the caller answers +/// NotFound rather than guessing. +pub fn arpa_qname_to_ip(qname: &str) -> Option { + let name = qname.trim_end_matches('.').to_ascii_lowercase(); + + if let Some(reversed) = name.strip_suffix(".in-addr.arpa") { + // Four decimal octets, least-significant label first. + let octets: Vec<&str> = reversed.split('.').collect(); + if octets.len() != 4 { + return None; + } + let mut addr = [0u8; 4]; + for (byte, octet) in addr.iter_mut().zip(octets.iter().rev()) { + *byte = octet.parse().ok()?; + } + Some(IpAddr::V4(Ipv4Addr::from(addr))) + } else if let Some(reversed) = name.strip_suffix(".ip6.arpa") { + // Thirty-two hex nibbles, least-significant label first. + let nibbles: Vec<&str> = reversed.split('.').collect(); + if nibbles.len() != 32 { + return None; + } + let mut addr = [0u8; 16]; + for (i, nibble) in nibbles.iter().rev().enumerate() { + if nibble.len() != 1 { + return None; + } + let value = u8::from_str_radix(nibble, 16).ok()?; + if i % 2 == 0 { + addr[i / 2] = value << 4; + } else { + addr[i / 2] |= value; + } + } + Some(IpAddr::V6(Ipv6Addr::from(addr))) + } else { + None + } +} + #[cfg(test)] mod tests { use carbide_test_support::Outcome::*; @@ -39,6 +83,38 @@ mod tests { assert_eq!(normalized, expected); } + #[test] + fn parses_arpa_qname_to_ip() { + use std::net::{IpAddr, Ipv4Addr}; + + use carbide_test_support::value_scenarios; + + value_scenarios!( + run = |qname: &str| super::arpa_qname_to_ip(qname); + "ipv4 in-addr.arpa" { + "1.0.168.192.in-addr.arpa." => Some(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))), + "3.2.1.10.in-addr.arpa." => Some(IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3))), + } + "ipv6 ip6.arpa" { + "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa." + => Some("2001:db8::1".parse::().unwrap()), + "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa." + => Some("::1".parse::().unwrap()), + } + "rejects non-arpa and malformed" { + "host.example.com." => None, + "1.2.3.in-addr.arpa." => None, + "300.0.0.0.in-addr.arpa." => None, + "1.0.168.192.in-addr.arpa.extra." => None, + } + "normalizes case" { + "1.0.168.192.IN-ADDR.ARPA." => Some(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))), + "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.B.D.0.1.0.0.2.IP6.ARPA." + => Some("2001:db8::1".parse::().unwrap()), + } + ); + } + #[crate::sqlx_test] async fn test_dns_hostname_from_ipv6_expands_to_rust_format(pool: sqlx::PgPool) { check_cases_async(