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::{
8    build_authenticated_client, build_delete_rrset_record, build_record_fqdn, effective_record_ttl,
9    rrset_ttl_matches, should_update_record,
10};
11use anyhow::{Context, 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 SRV fields and TTL.
19///
20/// # Arguments
21///
22/// * `existing_records` - Records currently in DNS (from query)
23/// * `priority` - Desired SRV priority from the spec
24/// * `weight` - Desired SRV weight from the spec
25/// * `port` - Desired SRV port from the spec
26/// * `target` - Desired SRV target host from the spec
27/// * `desired_ttl` - Effective TTL from the spec
28///
29/// # Returns
30///
31/// `true` if the existing `RRset` matches the desired state exactly (no changes
32/// needed), `false` if an update is required (rdata or TTL differ).
33fn compare_srv_rrset(
34    existing_records: &[Record],
35    priority: u16,
36    weight: u16,
37    port: u16,
38    target: &str,
39    desired_ttl: u32,
40) -> bool {
41    if existing_records.len() != 1 {
42        return false;
43    }
44    if !rrset_ttl_matches(existing_records, desired_ttl) {
45        return false;
46    }
47    let RData::SRV(existing_srv) = &existing_records[0].data else {
48        return false;
49    };
50    existing_srv.priority == priority
51        && existing_srv.weight == weight
52        && existing_srv.port == port
53        && existing_srv.target.to_string() == target
54}
55
56/// Add an SRV record using dynamic DNS update (RFC 2136) with `RRset` synchronization.
57///
58/// If the existing `RRset` differs from the desired state, the entire SRV
59/// `RRset` for the name is deleted and recreated so stale rdata never lingers.
60///
61/// # Errors
62///
63/// Returns an error if:
64/// - DNS server connection fails
65/// - TSIG signer creation fails
66/// - DNS update is rejected by the server
67/// - Invalid domain name or target
68#[allow(clippy::too_many_arguments)]
69pub async fn add_srv_record(
70    zone_name: &str,
71    name: &str,
72    srv_data: &SRVRecordData,
73    server: &str,
74    key_data: &RndcKeyData,
75) -> Result<()> {
76    let priority_u16 = u16::try_from(srv_data.priority)
77        .context(format!("Invalid SRV priority: {}", srv_data.priority))?;
78    let weight_u16 = u16::try_from(srv_data.weight)
79        .context(format!("Invalid SRV weight: {}", srv_data.weight))?;
80    let port_u16 =
81        u16::try_from(srv_data.port).context(format!("Invalid SRV port: {}", srv_data.port))?;
82    let ttl_value = effective_record_ttl(srv_data.ttl);
83
84    let should_update = should_update_record(
85        zone_name,
86        name,
87        RecordType::SRV,
88        "SRV",
89        server,
90        |existing_records| {
91            compare_srv_rrset(
92                existing_records,
93                priority_u16,
94                weight_u16,
95                port_u16,
96                &srv_data.target,
97                ttl_value,
98            )
99        },
100    )
101    .await?;
102
103    if !should_update {
104        return Ok(());
105    }
106
107    let zone =
108        Name::from_str(zone_name).context(format!("Invalid zone name for SRV: {zone_name}"))?;
109    let fqdn = build_record_fqdn(zone_name, name)?;
110    let target_name = Name::from_str(&srv_data.target).context(format!(
111        "Invalid target for SRV record: {}",
112        srv_data.target
113    ))?;
114
115    let record_data = rdata::SRV::new(priority_u16, weight_u16, port_u16, target_name);
116    let mut record = Record::from_rdata(fqdn.clone(), ttl_value, RData::SRV(record_data));
117    record.dns_class = DNSClass::IN;
118
119    let mut client = build_authenticated_client(server, key_data).await?;
120
121    // Step 1: delete existing RRset (ignore errors — may not exist).
122    let delete_record = build_delete_rrset_record(&fqdn, RecordType::SRV);
123    let _ = client.delete_rrset(delete_record, zone.clone()).await;
124
125    // Step 2: append the desired record to create the new RRset.
126    let response = client
127        .append(record, zone, false)
128        .await
129        .context(format!("Failed to send SRV record update for {fqdn}"))?;
130
131    match response.metadata.response_code {
132        ResponseCode::NoError => {
133            info!(
134                "Successfully added SRV record: {} -> {}:{} (priority: {}, weight: {}, TTL: {})",
135                fqdn, srv_data.target, srv_data.port, srv_data.priority, srv_data.weight, ttl_value
136            );
137            Ok(())
138        }
139        code => {
140            anyhow::bail!("DNS server rejected SRV record update for {fqdn}: {code:?}");
141        }
142    }
143}
144
145#[cfg(test)]
146#[path = "srv_tests.rs"]
147mod srv_tests;