bindy/bind9/records/
srv.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! SRV record management.
5
6use super::super::types::{RndcKeyData, SRVRecordData};
7use super::should_update_record;
8use anyhow::{Context, 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 SRV record using dynamic DNS update (RFC 2136).
20///
21/// # Errors
22///
23/// Returns an error if:
24/// - DNS server connection fails
25/// - TSIG signer creation fails
26/// - DNS update is rejected by the server
27/// - Invalid domain name or target
28#[allow(clippy::too_many_arguments)]
29pub async fn add_srv_record(
30    zone_name: &str,
31    name: &str,
32    srv_data: &SRVRecordData,
33    server: &str,
34    key_data: &RndcKeyData,
35) -> Result<()> {
36    use hickory_client::rr::RecordType;
37
38    // Check if update is needed using declarative reconciliation pattern
39    let srv_data_for_comparison = srv_data.clone();
40    let should_update = should_update_record(
41        zone_name,
42        name,
43        RecordType::SRV,
44        "SRV",
45        server,
46        |existing_records| {
47            // Compare: should return true if records match desired state
48            if existing_records.len() == 1 {
49                if let Some(RData::SRV(existing_srv)) = existing_records[0].data() {
50                    let priority_match = existing_srv.priority()
51                        == u16::try_from(srv_data_for_comparison.priority).unwrap_or(0);
52                    let weight_match = existing_srv.weight()
53                        == u16::try_from(srv_data_for_comparison.weight).unwrap_or(0);
54                    let port_match = existing_srv.port()
55                        == u16::try_from(srv_data_for_comparison.port).unwrap_or(0);
56                    let target_match =
57                        existing_srv.target().to_string() == srv_data_for_comparison.target;
58                    return priority_match && weight_match && port_match && target_match;
59                }
60            }
61            false
62        },
63    )
64    .await?;
65
66    if !should_update {
67        return Ok(());
68    }
69
70    let zone_name_str = zone_name.to_string();
71    let name_str = name.to_string();
72    let target_str = srv_data.target.clone();
73    let port = srv_data.port;
74    let priority = srv_data.priority;
75    let weight = srv_data.weight;
76    let ttl = srv_data.ttl;
77    let server_str = server.to_string();
78    let key_data = key_data.clone();
79
80    tokio::task::spawn_blocking(move || {
81        let server_addr = server_str.parse::<std::net::SocketAddr>().context(format!(
82            "Invalid server address for SRV record update: {server_str}"
83        ))?;
84
85        let conn = UdpClientConnection::new(server_addr)
86            .context("Failed to create UDP connection for SRV record")?;
87
88        let signer = create_tsig_signer(&key_data)
89            .context("Failed to create TSIG signer for SRV record")?;
90
91        let client = SyncClient::with_tsigner(conn, signer);
92
93        let fqdn_str = if name_str.is_empty() || name_str == "@" {
94            zone_name_str.clone()
95        } else {
96            format!("{name_str}.{zone_name_str}")
97        };
98
99        let fqdn = Name::from_str(&fqdn_str)
100            .context(format!("Invalid FQDN for SRV record: {fqdn_str}"))?;
101
102        let zone = Name::from_str(&zone_name_str)
103            .context(format!("Invalid zone name for SRV: {zone_name_str}"))?;
104
105        let target_name = Name::from_str(&target_str)
106            .context(format!("Invalid target for SRV record: {target_str}"))?;
107
108        let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS))
109            .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300));
110
111        // Convert i32 to u16 for SRV record parameters
112        let priority_u16 =
113            u16::try_from(priority).context(format!("Invalid SRV priority: {priority}"))?;
114        let weight_u16 =
115            u16::try_from(weight).context(format!("Invalid SRV weight: {weight}"))?;
116        let port_u16 = u16::try_from(port).context(format!("Invalid SRV port: {port}"))?;
117
118        let record_data = rdata::SRV::new(priority_u16, weight_u16, port_u16, target_name);
119
120        let mut record = Record::from_rdata(fqdn.clone(), ttl_value, RData::SRV(record_data));
121        record.set_dns_class(DNSClass::IN);
122
123        // Use append for idempotent operation
124        let response = client
125            .append(record, zone.clone(), false)
126            .context(format!("Failed to send SRV record update for {fqdn_str}"))?;
127
128        match response.response_code() {
129            ResponseCode::NoError => {
130                info!(
131                    "Successfully added SRV record: {} -> {}:{} (priority: {}, weight: {}, TTL: {})",
132                    fqdn_str, target_str, port, priority, weight, ttl_value
133                );
134            }
135            code => {
136                anyhow::bail!("DNS server rejected SRV record update for {fqdn_str}: {code:?}");
137            }
138        }
139
140        Ok(())
141    })
142    .await
143    .context("SRV record update task panicked")??;
144
145    Ok(())
146}
147
148#[cfg(test)]
149#[path = "srv_tests.rs"]
150mod srv_tests;