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::should_update_record;
8use anyhow::Result;
9use hickory_client::client::{Client, SyncClient};
10use hickory_client::op::ResponseCode;
11use hickory_client::rr::{rdata, DNSClass, Name, RData, Record};
12use hickory_client::udp::UdpClientConnection;
13use std::str::FromStr;
14use tracing::info;
15
16use crate::bind9::rndc::create_tsig_signer;
17use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS;
18
19/// Add a TXT record using dynamic DNS update (RFC 2136).
20///
21/// # Errors
22///
23/// Returns an error if the DNS update fails or the server rejects it.
24#[allow(clippy::too_many_arguments)]
25pub async fn add_txt_record(
26    zone_name: &str,
27    name: &str,
28    texts: &[String],
29    ttl: Option<i32>,
30    server: &str,
31    key_data: &RndcKeyData,
32) -> Result<()> {
33    use hickory_client::rr::RecordType;
34
35    // Check if update is needed using declarative reconciliation pattern
36    let texts_for_comparison = texts.to_vec();
37    let should_update = should_update_record(
38        zone_name,
39        name,
40        RecordType::TXT,
41        "TXT",
42        server,
43        |existing_records| {
44            // Compare: should return true if records match desired state
45            if existing_records.len() == 1 {
46                if let Some(RData::TXT(existing_txt)) = existing_records[0].data() {
47                    // Extract text data from TXT record
48                    let existing_texts: Vec<String> = existing_txt
49                        .txt_data()
50                        .iter()
51                        .map(|bytes| String::from_utf8_lossy(bytes).to_string())
52                        .collect();
53                    return existing_texts == texts_for_comparison;
54                }
55            }
56            false
57        },
58    )
59    .await?;
60
61    if !should_update {
62        return Ok(());
63    }
64
65    let zone_name_str = zone_name.to_string();
66    let name_str = name.to_string();
67    let texts_vec: Vec<String> = texts.to_vec();
68    let server_str = server.to_string();
69    let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS))
70        .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300));
71    let key_data = key_data.clone();
72
73    tokio::task::spawn_blocking(move || {
74        let server_addr = server_str.parse::<std::net::SocketAddr>()?;
75        let conn = UdpClientConnection::new(server_addr)?;
76        let signer = create_tsig_signer(&key_data)?;
77        let client = SyncClient::with_tsigner(conn, signer);
78
79        let zone = Name::from_str(&zone_name_str)?;
80        let fqdn = if name_str == "@" || name_str.is_empty() {
81            zone.clone()
82        } else {
83            Name::from_str(&format!("{name_str}.{zone_name_str}"))?
84        };
85
86        let txt_rdata = rdata::TXT::new(texts_vec.clone());
87        let mut record = Record::from_rdata(fqdn.clone(), ttl_value, RData::TXT(txt_rdata));
88        record.set_dns_class(DNSClass::IN);
89
90        info!(
91            "Adding TXT record: {} -> {:?} (TTL: {})",
92            record.name(),
93            texts_vec,
94            ttl_value
95        );
96        // Use append for idempotent operation (must_exist=false for no prerequisites)
97        let response = client.append(record, zone, false)?;
98
99        match response.response_code() {
100            ResponseCode::NoError => {
101                info!("Successfully added TXT record: {}", name_str);
102                Ok(())
103            }
104            code => Err(anyhow::anyhow!(
105                "DNS update failed with response code: {code:?}"
106            )),
107        }
108    })
109    .await?
110}
111
112#[cfg(test)]
113#[path = "txt_tests.rs"]
114mod txt_tests;