bindy/bind9/records/
ptr.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! PTR record management.
5
6use super::super::types::{PTRRecordData, RndcKeyData};
7use super::{
8    build_authenticated_client, build_delete_rrset_record, build_record_fqdn, effective_record_ttl,
9    rrset_ttl_matches, should_update_record,
10};
11use anyhow::{Context, Result};
12use hickory_net::client::ClientHandle;
13use hickory_proto::op::ResponseCode;
14use hickory_proto::rr::{rdata, DNSClass, Name, RData, Record, RecordType};
15use std::str::FromStr;
16use tracing::info;
17
18/// Compare existing DNS `RRset` with the desired PTR target and TTL.
19///
20/// # Arguments
21///
22/// * `existing_records` - Records currently in DNS (from query)
23/// * `target` - Desired PTR target host from the spec
24/// * `desired_ttl` - Effective TTL from the spec
25///
26/// # Returns
27///
28/// `true` if the existing `RRset` matches the desired state exactly (no changes
29/// needed), `false` if an update is required (rdata or TTL differ).
30fn compare_ptr_rrset(existing_records: &[Record], target: &str, desired_ttl: u32) -> bool {
31    if existing_records.len() != 1 {
32        return false;
33    }
34    if !rrset_ttl_matches(existing_records, desired_ttl) {
35        return false;
36    }
37    let RData::PTR(existing_ptr) = &existing_records[0].data else {
38        return false;
39    };
40    existing_ptr.0.to_string() == target
41}
42
43/// Add a PTR record using dynamic DNS update (RFC 2136) with `RRset` synchronization.
44///
45/// If the existing `RRset` differs from the desired state, the entire PTR
46/// `RRset` for the name is deleted and recreated so stale rdata never lingers.
47///
48/// # Errors
49///
50/// Returns an error if:
51/// - DNS server connection fails
52/// - TSIG signer creation fails
53/// - DNS update is rejected by the server
54/// - Invalid domain name or target
55#[allow(clippy::too_many_arguments)]
56pub async fn add_ptr_record(
57    zone_name: &str,
58    name: &str,
59    ptr_data: &PTRRecordData,
60    server: &str,
61    key_data: &RndcKeyData,
62) -> Result<()> {
63    let ttl_value = effective_record_ttl(ptr_data.ttl);
64
65    let should_update = should_update_record(
66        zone_name,
67        name,
68        RecordType::PTR,
69        "PTR",
70        server,
71        |existing_records| compare_ptr_rrset(existing_records, &ptr_data.target, ttl_value),
72    )
73    .await?;
74
75    if !should_update {
76        return Ok(());
77    }
78
79    let zone =
80        Name::from_str(zone_name).context(format!("Invalid zone name for PTR: {zone_name}"))?;
81    let fqdn = build_record_fqdn(zone_name, name)?;
82    let target_name = Name::from_str(&ptr_data.target).context(format!(
83        "Invalid target for PTR record: {}",
84        ptr_data.target
85    ))?;
86
87    let mut record =
88        Record::from_rdata(fqdn.clone(), ttl_value, RData::PTR(rdata::PTR(target_name)));
89    record.dns_class = DNSClass::IN;
90
91    let mut client = build_authenticated_client(server, key_data).await?;
92
93    // Step 1: delete existing RRset (ignore errors — may not exist).
94    let delete_record = build_delete_rrset_record(&fqdn, RecordType::PTR);
95    let _ = client.delete_rrset(delete_record, zone.clone()).await;
96
97    // Step 2: append the desired record to create the new RRset.
98    let response = client
99        .append(record, zone, false)
100        .await
101        .context(format!("Failed to send PTR record update for {fqdn}"))?;
102
103    match response.metadata.response_code {
104        ResponseCode::NoError => {
105            info!(
106                "Successfully added PTR record: {} -> {} (TTL: {})",
107                fqdn, ptr_data.target, ttl_value
108            );
109            Ok(())
110        }
111        code => {
112            anyhow::bail!("DNS server rejected PTR record update for {fqdn}: {code:?}");
113        }
114    }
115}
116
117#[cfg(test)]
118#[path = "ptr_tests.rs"]
119mod ptr_tests;