bindy/bind9/records/
mx.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! MX 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 an MX 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_mx_record(
26    zone_name: &str,
27    name: &str,
28    priority: i32,
29    mail_server: &str,
30    ttl: Option<i32>,
31    server: &str,
32    key_data: &RndcKeyData,
33) -> Result<()> {
34    use hickory_client::rr::RecordType;
35
36    // Check if update is needed using declarative reconciliation pattern
37    let mail_server_for_comparison = mail_server.to_string();
38    let priority_u16 = u16::try_from(priority).unwrap_or(10);
39    let should_update = should_update_record(
40        zone_name,
41        name,
42        RecordType::MX,
43        "MX",
44        server,
45        |existing_records| {
46            // Compare: should return true if records match desired state
47            if existing_records.len() == 1 {
48                if let Some(RData::MX(existing_mx)) = existing_records[0].data() {
49                    return existing_mx.preference() == priority_u16
50                        && existing_mx.exchange().to_string() == mail_server_for_comparison;
51                }
52            }
53            false
54        },
55    )
56    .await?;
57
58    if !should_update {
59        return Ok(());
60    }
61
62    let zone_name_str = zone_name.to_string();
63    let name_str = name.to_string();
64    let mail_server_str = mail_server.to_string();
65    let server_str = server.to_string();
66    let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS))
67        .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300));
68    let key_data = key_data.clone();
69
70    tokio::task::spawn_blocking(move || {
71        let server_addr = server_str.parse::<std::net::SocketAddr>()?;
72        let conn = UdpClientConnection::new(server_addr)?;
73        let signer = create_tsig_signer(&key_data)?;
74        let client = SyncClient::with_tsigner(conn, signer);
75
76        let zone = Name::from_str(&zone_name_str)?;
77        let fqdn = if name_str == "@" || name_str.is_empty() {
78            zone.clone()
79        } else {
80            Name::from_str(&format!("{name_str}.{zone_name_str}"))?
81        };
82
83        let mx_name = Name::from_str(&mail_server_str)?;
84        let mx_rdata = rdata::MX::new(priority_u16, mx_name);
85        let mut record = Record::from_rdata(fqdn.clone(), ttl_value, RData::MX(mx_rdata));
86        record.set_dns_class(DNSClass::IN);
87
88        // Use append for idempotent operation (must_exist=false for no prerequisites)
89        info!(
90            "Adding MX record: {} -> {} (priority: {}, TTL: {})",
91            fqdn, mail_server_str, priority_u16, ttl_value
92        );
93        let response = client.append(record, zone, false)?;
94
95        match response.response_code() {
96            ResponseCode::NoError => {
97                info!(
98                    "Successfully added MX record: {} -> {}",
99                    name_str, mail_server_str
100                );
101                Ok(())
102            }
103            code => Err(anyhow::anyhow!(
104                "DNS update failed with response code: {code:?}"
105            )),
106        }
107    })
108    .await?
109}
110
111#[cfg(test)]
112#[path = "mx_tests.rs"]
113mod mx_tests;