Skip to content

Commit 5b16105

Browse files
authored
feat(dns): parse reverse-DNS (PTR) query names into IP addresses (#2691)
## Summary The conversion foundation for reverse DNS (PTR). A PTR query name is an address in `in-addr.arpa` / `ip6.arpa` form; resolving it means going **name → address**, which is a clean inverse — so this does it in Rust instead of a large PL/pgSQL function: - `arpa_qname_to_ip(&str) -> Option<IpAddr>` reverses the four octets (IPv4) or 32 nibbles (IPv6); `Ipv6Addr` assembles the address, so the IPv6 case is a few lines. Returns `None` for non-arpa / malformed names. - Table tests (`value_scenarios!`) cover IPv4, IPv6, and the reject cases. The PTR handler will parse the incoming qname with this and look the interface up by **address** (an indexed `WHERE address = $ip`), rather than computing an arpa string for every row in a view. The address index + `find_ptr_record` land with #2639; the handler with #2641. > **Reworked from the original SQL approach** (a `nico_ip_to_arpa_qname` PL/pgSQL function + `dns_records_ptr` view). Doing the inverse in Rust avoids a large SQL function, keeps the logic testable in one place, and makes the lookup a plain indexed address equality. The existing `nico_inet_to_dns_hostname` (forward instance DNS) is tracked for the same treatment separately. Implements #2637. Part of the #2630 epic (reverse DNS / PTR records). Draft pending review. Signed-off-by: Chet Nichols III <chetn@nvidia.com>
1 parent 3984006 commit 5b16105

1 file changed

Lines changed: 76 additions & 0 deletions

File tree

crates/api-db/src/dns/mod.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,56 @@ pub mod domain;
1919
pub mod domain_metadata;
2020
pub mod resource_record;
2121

22+
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
23+
2224
pub fn normalize_domain(name: &str) -> String {
2325
let normalize_domain = name.trim_end_matches('.').to_lowercase();
2426
tracing::debug!("Normalized domain name: {} to: {}", name, normalize_domain);
2527
normalize_domain
2628
}
2729

30+
/// Parse a reverse-DNS (PTR) query name into the address it points at -- the
31+
/// inverse of the `in-addr.arpa` (IPv4) / `ip6.arpa` (IPv6) form. Returns `None`
32+
/// for anything that is not a well-formed arpa name, so the caller answers
33+
/// NotFound rather than guessing.
34+
pub fn arpa_qname_to_ip(qname: &str) -> Option<IpAddr> {
35+
let name = qname.trim_end_matches('.').to_ascii_lowercase();
36+
37+
if let Some(reversed) = name.strip_suffix(".in-addr.arpa") {
38+
// Four decimal octets, least-significant label first.
39+
let octets: Vec<&str> = reversed.split('.').collect();
40+
if octets.len() != 4 {
41+
return None;
42+
}
43+
let mut addr = [0u8; 4];
44+
for (byte, octet) in addr.iter_mut().zip(octets.iter().rev()) {
45+
*byte = octet.parse().ok()?;
46+
}
47+
Some(IpAddr::V4(Ipv4Addr::from(addr)))
48+
} else if let Some(reversed) = name.strip_suffix(".ip6.arpa") {
49+
// Thirty-two hex nibbles, least-significant label first.
50+
let nibbles: Vec<&str> = reversed.split('.').collect();
51+
if nibbles.len() != 32 {
52+
return None;
53+
}
54+
let mut addr = [0u8; 16];
55+
for (i, nibble) in nibbles.iter().rev().enumerate() {
56+
if nibble.len() != 1 {
57+
return None;
58+
}
59+
let value = u8::from_str_radix(nibble, 16).ok()?;
60+
if i % 2 == 0 {
61+
addr[i / 2] = value << 4;
62+
} else {
63+
addr[i / 2] |= value;
64+
}
65+
}
66+
Some(IpAddr::V6(Ipv6Addr::from(addr)))
67+
} else {
68+
None
69+
}
70+
}
71+
2872
#[cfg(test)]
2973
mod tests {
3074
use carbide_test_support::Outcome::*;
@@ -39,6 +83,38 @@ mod tests {
3983
assert_eq!(normalized, expected);
4084
}
4185

86+
#[test]
87+
fn parses_arpa_qname_to_ip() {
88+
use std::net::{IpAddr, Ipv4Addr};
89+
90+
use carbide_test_support::value_scenarios;
91+
92+
value_scenarios!(
93+
run = |qname: &str| super::arpa_qname_to_ip(qname);
94+
"ipv4 in-addr.arpa" {
95+
"1.0.168.192.in-addr.arpa." => Some(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))),
96+
"3.2.1.10.in-addr.arpa." => Some(IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3))),
97+
}
98+
"ipv6 ip6.arpa" {
99+
"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."
100+
=> Some("2001:db8::1".parse::<IpAddr>().unwrap()),
101+
"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."
102+
=> Some("::1".parse::<IpAddr>().unwrap()),
103+
}
104+
"rejects non-arpa and malformed" {
105+
"host.example.com." => None,
106+
"1.2.3.in-addr.arpa." => None,
107+
"300.0.0.0.in-addr.arpa." => None,
108+
"1.0.168.192.in-addr.arpa.extra." => None,
109+
}
110+
"normalizes case" {
111+
"1.0.168.192.IN-ADDR.ARPA." => Some(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))),
112+
"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."
113+
=> Some("2001:db8::1".parse::<IpAddr>().unwrap()),
114+
}
115+
);
116+
}
117+
42118
#[crate::sqlx_test]
43119
async fn test_dns_hostname_from_ipv6_expands_to_rust_format(pool: sqlx::PgPool) {
44120
check_cases_async(

0 commit comments

Comments
 (0)