Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions crates/api-db/migrations/20260618070345_reverse_dns_ptr_records.sql
Original file line number Diff line number Diff line change
@@ -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';
63 changes: 63 additions & 0 deletions crates/api-db/src/dns/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
),
},
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
|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(
Expand Down
Loading