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::{
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/// Default MX preference used when the spec priority cannot be represented as `u16`.
19const DEFAULT_MX_PREFERENCE: u16 = 10;
20
21/// Compare existing DNS `RRset` with the desired MX preference, exchange, and TTL.
22///
23/// # Arguments
24///
25/// * `existing_records` - Records currently in DNS (from query)
26/// * `preference` - Desired MX preference (priority) from the spec
27/// * `mail_server` - Desired mail exchange host from the spec
28/// * `desired_ttl` - Effective TTL from the spec
29///
30/// # Returns
31///
32/// `true` if the existing `RRset` matches the desired state exactly (no changes
33/// needed), `false` if an update is required (rdata or TTL differ).
34fn compare_mx_rrset(
35    existing_records: &[Record],
36    preference: u16,
37    mail_server: &str,
38    desired_ttl: u32,
39) -> bool {
40    if existing_records.len() != 1 {
41        return false;
42    }
43    if !rrset_ttl_matches(existing_records, desired_ttl) {
44        return false;
45    }
46    let RData::MX(existing_mx) = &existing_records[0].data else {
47        return false;
48    };
49    existing_mx.preference == preference && existing_mx.exchange.to_string() == mail_server
50}
51
52/// Add an MX record using dynamic DNS update (RFC 2136) with `RRset` synchronization.
53///
54/// If the existing `RRset` differs from the desired state, the entire MX
55/// `RRset` for the name is deleted and recreated so stale rdata (e.g. an old
56/// mail server) never lingers.
57///
58/// # Errors
59///
60/// Returns an error if the DNS update fails or the server rejects it.
61#[allow(clippy::too_many_arguments)]
62pub async fn add_mx_record(
63    zone_name: &str,
64    name: &str,
65    priority: i32,
66    mail_server: &str,
67    ttl: Option<i32>,
68    server: &str,
69    key_data: &RndcKeyData,
70) -> Result<()> {
71    let priority_u16 = u16::try_from(priority).unwrap_or(DEFAULT_MX_PREFERENCE);
72    let ttl_value = effective_record_ttl(ttl);
73    let should_update = should_update_record(
74        zone_name,
75        name,
76        RecordType::MX,
77        "MX",
78        server,
79        |existing_records| compare_mx_rrset(existing_records, priority_u16, mail_server, ttl_value),
80    )
81    .await?;
82
83    if !should_update {
84        return Ok(());
85    }
86
87    let zone = Name::from_str(zone_name)?;
88    let fqdn = build_record_fqdn(zone_name, name)?;
89    let mx_name = Name::from_str(mail_server)?;
90
91    let mut record = Record::from_rdata(
92        fqdn.clone(),
93        ttl_value,
94        RData::MX(rdata::MX::new(priority_u16, mx_name)),
95    );
96    record.dns_class = DNSClass::IN;
97
98    info!(
99        "Adding MX record: {} -> {} (priority: {}, TTL: {})",
100        fqdn, mail_server, priority_u16, ttl_value
101    );
102
103    let mut client = build_authenticated_client(server, key_data).await?;
104
105    // Step 1: delete existing RRset (ignore errors — may not exist).
106    let delete_record = build_delete_rrset_record(&fqdn, RecordType::MX);
107    let _ = client.delete_rrset(delete_record, zone.clone()).await;
108
109    // Step 2: append the desired record to create the new RRset.
110    let response = client.append(record, zone, false).await?;
111
112    match response.metadata.response_code {
113        ResponseCode::NoError => {
114            info!("Successfully added MX record: {} -> {}", name, mail_server);
115            Ok(())
116        }
117        code => Err(anyhow::anyhow!(
118            "DNS update failed with response code: {code:?}"
119        )),
120    }
121}
122
123#[cfg(test)]
124#[path = "mx_tests.rs"]
125mod mx_tests;