bindy/bind9/records/
txt.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! TXT record management.
5
6use super::super::types::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::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 TXT strings and TTL.
19///
20/// # Arguments
21///
22/// * `existing_records` - Records currently in DNS (from query)
23/// * `texts` - Desired TXT strings 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_txt_rrset(existing_records: &[Record], texts: &[String], 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::TXT(existing_txt) = &existing_records[0].data else {
38        return false;
39    };
40    let existing_texts: Vec<String> = existing_txt
41        .txt_data
42        .iter()
43        .map(|bytes| String::from_utf8_lossy(bytes).to_string())
44        .collect();
45    existing_texts == texts
46}
47
48/// Add a TXT record using dynamic DNS update (RFC 2136) with `RRset` synchronization.
49///
50/// If the existing `RRset` differs from the desired state, the entire TXT
51/// `RRset` for the name is deleted and recreated so stale rdata never lingers.
52///
53/// # Errors
54///
55/// Returns an error if the DNS update fails or the server rejects it.
56#[allow(clippy::too_many_arguments)]
57pub async fn add_txt_record(
58    zone_name: &str,
59    name: &str,
60    texts: &[String],
61    ttl: Option<i32>,
62    server: &str,
63    key_data: &RndcKeyData,
64) -> Result<()> {
65    let ttl_value = effective_record_ttl(ttl);
66    let should_update = should_update_record(
67        zone_name,
68        name,
69        RecordType::TXT,
70        "TXT",
71        server,
72        |existing_records| compare_txt_rrset(existing_records, texts, ttl_value),
73    )
74    .await?;
75
76    if !should_update {
77        return Ok(());
78    }
79
80    let zone = Name::from_str(zone_name)?;
81    let fqdn = build_record_fqdn(zone_name, name)?;
82
83    let mut record = Record::from_rdata(
84        fqdn.clone(),
85        ttl_value,
86        RData::TXT(rdata::TXT::new(texts.to_vec())),
87    );
88    record.dns_class = DNSClass::IN;
89
90    info!(
91        "Adding TXT record: {} -> {:?} (TTL: {})",
92        record.name, texts, ttl_value
93    );
94
95    let mut client = build_authenticated_client(server, key_data).await?;
96
97    // Step 1: delete existing RRset (ignore errors — may not exist).
98    let delete_record = build_delete_rrset_record(&fqdn, RecordType::TXT);
99    let _ = client.delete_rrset(delete_record, zone.clone()).await;
100
101    // Step 2: append the desired record to create the new RRset.
102    let response = client.append(record, zone, false).await?;
103
104    match response.metadata.response_code {
105        ResponseCode::NoError => {
106            info!("Successfully added TXT record: {}", name);
107            Ok(())
108        }
109        code => Err(anyhow::anyhow!(
110            "DNS update failed with response code: {code:?}"
111        )),
112    }
113}
114
115#[cfg(test)]
116#[path = "txt_tests.rs"]
117mod txt_tests;