From 38244e1e3e11489f417892a9230dd170eb6ad697 Mon Sep 17 00:00:00 2001 From: Chet Nichols III Date: Thu, 18 Jun 2026 00:08:20 -0700 Subject: [PATCH] feat(dns): derive PTR records from machine interface addresses Adds the database foundation for reverse DNS. A new `nico_ip_to_arpa_qname(inet)` function turns an address into its PTR query name -- reversed octets under `in-addr.arpa` for IPv4, reversed nibbles under `ip6.arpa` for IPv6 -- reusing the IPv6 expansion from `nico_inet_to_dns_hostname` so the two address-derived helpers agree. A new `dns_records_ptr` view answers each interface address's arpa name with the FQDN the forward shortname view already publishes for that interface. The view's `WHERE` matches `dns_records_shortname_combined` exactly (primary or BMC interfaces), so a forward A/AAAA record and its PTR round-trip -- the PTR resolves to the same name the forward record came from. Nothing serves these records yet; the api-db query, the api-core handler arm, and the carbide-dns unlock are the follow-on tasks. Tests cover the function's IPv4 and IPv6 arpa forms (dotted-quad, documentation prefix, loopback, unspecified) with a DB-backed test against Postgres. This supports https://github.com/NVIDIA/infra-controller/issues/2637. Signed-off-by: Chet Nichols III --- ...20260618070345_reverse_dns_ptr_records.sql | 101 ++++++++++++++++++ crates/api-db/src/dns/mod.rs | 63 +++++++++++ 2 files changed, 164 insertions(+) create mode 100644 crates/api-db/migrations/20260618070345_reverse_dns_ptr_records.sql diff --git a/crates/api-db/migrations/20260618070345_reverse_dns_ptr_records.sql b/crates/api-db/migrations/20260618070345_reverse_dns_ptr_records.sql new file mode 100644 index 0000000000..ead3e6f2ee --- /dev/null +++ b/crates/api-db/migrations/20260618070345_reverse_dns_ptr_records.sql @@ -0,0 +1,101 @@ +-- Reverse DNS (PTR) support. Convert an inet address to its PTR query name in +-- the in-addr.arpa (IPv4) or ip6.arpa (IPv6) zone, and expose PTR records +-- derived from machine interface addresses. The IPv6 expansion mirrors +-- nico_inet_to_dns_hostname so the two address-derived helpers stay in agreement. + +CREATE OR REPLACE FUNCTION nico_ip_to_arpa_qname(address inet) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +STRICT +AS $$ +DECLARE + octets text[]; + address_text text; + parts text[]; + left_groups text[] := ARRAY[]::text[]; + right_groups text[] := ARRAY[]::text[]; + groups text[] := ARRAY[]::text[]; + embedded_ipv4_text text; + embedded_ipv4_parts text[]; + missing_groups integer; + nibbles text; +BEGIN + IF family(address) = 4 THEN + -- Reverse the four octets and append the in-addr.arpa zone. + octets := string_to_array(host(address), '.'); + RETURN octets[4] || '.' || octets[3] || '.' || octets[2] || '.' + || octets[1] || '.in-addr.arpa.'; + END IF; + + -- IPv6: expand to eight zero-padded hextets (same handling as + -- nico_inet_to_dns_hostname), concatenate to 32 hex nibbles, then reverse + -- and dot-separate them under the ip6.arpa zone. + address_text := host(address); + embedded_ipv4_text := substring( + address_text FROM '([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})$' + ); + IF embedded_ipv4_text IS NOT NULL THEN + embedded_ipv4_parts := string_to_array(embedded_ipv4_text, '.'); + address_text := regexp_replace( + address_text, + '([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})$', + to_hex(embedded_ipv4_parts[1]::integer * 256 + embedded_ipv4_parts[2]::integer) + || ':' + || to_hex(embedded_ipv4_parts[3]::integer * 256 + embedded_ipv4_parts[4]::integer) + ); + END IF; + + parts := regexp_split_to_array(address_text, '::'); + + IF parts[1] IS NOT NULL AND parts[1] != '' THEN + left_groups := string_to_array(parts[1], ':'); + END IF; + + IF cardinality(parts) > 1 AND parts[2] != '' THEN + right_groups := string_to_array(parts[2], ':'); + END IF; + + missing_groups := 8 - cardinality(left_groups) - cardinality(right_groups); + IF missing_groups < 0 THEN + RAISE EXCEPTION 'invalid IPv6 address expansion for %', address_text; + END IF; + + groups := left_groups; + IF missing_groups > 0 THEN + groups := groups || array_fill('0'::text, ARRAY[missing_groups]); + END IF; + groups := groups || right_groups; + + IF cardinality(groups) != 8 THEN + RAISE EXCEPTION 'invalid IPv6 address expansion for %', address_text; + END IF; + + SELECT string_agg(lpad(lower(group_text), 4, '0'), '' ORDER BY group_index) + INTO nibbles + FROM unnest(groups) WITH ORDINALITY AS expanded(group_text, group_index); + + RETURN regexp_replace(reverse(nibbles), '(.)', '\1.', 'g') || 'ip6.arpa.'; +END; +$$; + +-- PTR records for machine interface addresses. The qname is the address in its +-- arpa form; the answer is the same FQDN the forward shortname view publishes +-- for that interface, so a forward A/AAAA record and its PTR round-trip. The +-- WHERE clause mirrors dns_records_shortname_combined exactly (primary or BMC +-- interfaces) so every PTR has a matching forward record and vice versa. +CREATE VIEW dns_records_ptr AS +SELECT + nico_ip_to_arpa_qname(mia.address) AS q_name, + concat(mi.hostname, '.', d.name, '.') AS ptr_content, + 'PTR'::varchar(10) AS q_type, + COALESCE(meta.ttl, 300) AS ttl, + d.id AS domain_id +FROM + machine_interfaces mi + JOIN machine_interface_addresses mia ON mia.interface_id = mi.id + JOIN domains d ON d.id = mi.domain_id + LEFT JOIN dns_record_metadata meta ON meta.id = mi.id +WHERE + mi.primary_interface = TRUE + OR mi.interface_type = 'Bmc'; diff --git a/crates/api-db/src/dns/mod.rs b/crates/api-db/src/dns/mod.rs index 3a2ddfed7b..a85885c421 100644 --- a/crates/api-db/src/dns/mod.rs +++ b/crates/api-db/src/dns/mod.rs @@ -85,6 +85,69 @@ mod tests { .await; } + #[crate::sqlx_test] + async fn test_ip_to_arpa_qname(pool: sqlx::PgPool) { + check_cases_async( + [ + Case { + scenario: "ipv4 reverses octets under in-addr.arpa", + input: "192.168.0.1", + expect: Yields("1.0.168.192.in-addr.arpa.".to_string()), + }, + Case { + scenario: "ipv4 preserves octet order when reversed", + input: "10.1.2.3", + expect: Yields("3.2.1.10.in-addr.arpa.".to_string()), + }, + Case { + scenario: "ipv6 reverses nibbles under ip6.arpa", + input: "2001:db8::1", + expect: Yields( + "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." + .to_string(), + ), + }, + Case { + scenario: "ipv6 loopback keeps the low nibble", + input: "::1", + expect: Yields( + "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." + .to_string(), + ), + }, + Case { + scenario: "ipv6 unspecified expands every nibble", + input: "::", + expect: Yields( + "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.0.ip6.arpa." + .to_string(), + ), + }, + Case { + scenario: "ipv6-mapped ipv4 reverses the full 32 nibbles", + input: "::ffff:192.0.2.128", + expect: Yields( + "0.8.2.0.0.0.0.c.f.f.f.f.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa." + .to_string(), + ), + }, + ], + |address| { + // Clone the (Arc-backed) pool per case so the future owns it and + // the closure stays `Fn` — see check_cases_async's signature. + let pool = pool.clone(); + async move { + sqlx::query_scalar::<_, String>("SELECT nico_ip_to_arpa_qname($1::inet)") + .bind(address) + .fetch_one(&pool) + .await + .map_err(drop) + } + }, + ) + .await; + } + #[crate::sqlx_test] async fn test_dns_records_instance_ipv6_qname_expands_hostname(pool: sqlx::PgPool) { sqlx::query(