Skip to main content

sentinel_proxy/acme/dns/
propagation.rs

1//! DNS propagation checking for DNS-01 challenges
2//!
3//! Verifies that TXT records have propagated to authoritative nameservers
4//! before notifying the ACME server.
5
6use std::net::{IpAddr, Ipv4Addr, SocketAddr};
7use std::time::Duration;
8
9use hickory_resolver::config::{NameServerConfig, ResolverConfig, ResolverOpts};
10use hickory_resolver::name_server::TokioConnectionProvider;
11use hickory_resolver::proto::xfer::Protocol;
12use hickory_resolver::{Resolver, TokioResolver};
13use tokio::time::Instant;
14use tracing::{debug, trace, warn};
15
16use super::provider::{challenge_record_fqdn, DnsProviderError};
17
18/// Configuration for propagation checking
19#[derive(Debug, Clone)]
20pub struct PropagationConfig {
21    /// Delay before first check (allows DNS to start propagating)
22    pub initial_delay: Duration,
23    /// Interval between checks
24    pub check_interval: Duration,
25    /// Maximum time to wait for propagation
26    pub timeout: Duration,
27    /// Nameservers to query (empty = use defaults)
28    pub nameservers: Vec<IpAddr>,
29}
30
31impl Default for PropagationConfig {
32    fn default() -> Self {
33        Self {
34            initial_delay: Duration::from_secs(10),
35            check_interval: Duration::from_secs(5),
36            timeout: Duration::from_secs(120),
37            nameservers: vec![
38                IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), // Google DNS
39                IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), // Cloudflare DNS
40                IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), // Quad9
41            ],
42        }
43    }
44}
45
46/// DNS propagation checker
47///
48/// Verifies that DNS TXT records have propagated before ACME validation.
49#[derive(Debug)]
50pub struct PropagationChecker {
51    config: PropagationConfig,
52    resolver: TokioResolver,
53}
54
55impl PropagationChecker {
56    /// Create a new propagation checker with default configuration
57    pub fn new() -> Result<Self, DnsProviderError> {
58        Self::with_config(PropagationConfig::default())
59    }
60
61    /// Create a propagation checker with custom configuration
62    pub fn with_config(config: PropagationConfig) -> Result<Self, DnsProviderError> {
63        let resolver = Self::create_resolver(&config)?;
64
65        Ok(Self { config, resolver })
66    }
67
68    /// Create a DNS resolver with the configured nameservers
69    fn create_resolver(config: &PropagationConfig) -> Result<TokioResolver, DnsProviderError> {
70        let resolver_config = if config.nameservers.is_empty() {
71            ResolverConfig::default()
72        } else {
73            let mut resolver_config = ResolverConfig::new();
74            for ip in &config.nameservers {
75                resolver_config.add_name_server(NameServerConfig::new(
76                    SocketAddr::new(*ip, 53),
77                    Protocol::Udp,
78                ));
79            }
80            resolver_config
81        };
82
83        let mut opts = ResolverOpts::default();
84        opts.timeout = Duration::from_secs(5);
85        opts.attempts = 3;
86        opts.cache_size = 0; // Disable caching for propagation checks
87
88        // hickory-resolver 0.25 uses builder pattern
89        let resolver =
90            Resolver::builder_with_config(resolver_config, TokioConnectionProvider::default())
91                .with_options(opts)
92                .build();
93        Ok(resolver)
94    }
95
96    /// Wait for a TXT record to propagate
97    ///
98    /// # Arguments
99    ///
100    /// * `domain` - The domain for the challenge
101    /// * `expected_value` - The expected TXT record value
102    ///
103    /// # Returns
104    ///
105    /// `Ok(())` when the record is found with the expected value,
106    /// or an error if the timeout is reached.
107    pub async fn wait_for_propagation(
108        &self,
109        domain: &str,
110        expected_value: &str,
111    ) -> Result<(), DnsProviderError> {
112        let record_name = challenge_record_fqdn(domain);
113        let start = Instant::now();
114        let deadline = start + self.config.timeout;
115
116        debug!(
117            record = %record_name,
118            timeout_secs = self.config.timeout.as_secs(),
119            "Waiting for DNS propagation"
120        );
121
122        // Initial delay
123        tokio::time::sleep(self.config.initial_delay).await;
124
125        loop {
126            match self.check_record(&record_name, expected_value).await {
127                Ok(true) => {
128                    let elapsed = start.elapsed();
129                    debug!(
130                        record = %record_name,
131                        elapsed_secs = elapsed.as_secs(),
132                        "DNS propagation confirmed"
133                    );
134                    return Ok(());
135                }
136                Ok(false) => {
137                    trace!(record = %record_name, "Record not yet propagated");
138                }
139                Err(e) => {
140                    warn!(record = %record_name, error = %e, "DNS lookup error");
141                }
142            }
143
144            if Instant::now() > deadline {
145                return Err(DnsProviderError::Timeout {
146                    elapsed_secs: self.config.timeout.as_secs(),
147                });
148            }
149
150            tokio::time::sleep(self.config.check_interval).await;
151        }
152    }
153
154    /// Check if a TXT record exists with the expected value
155    async fn check_record(
156        &self,
157        record_name: &str,
158        expected_value: &str,
159    ) -> Result<bool, DnsProviderError> {
160        let lookup = self.resolver.txt_lookup(record_name).await;
161
162        match lookup {
163            Ok(records) => {
164                for record in records.iter() {
165                    // TXT records can have multiple strings, join them
166                    let value: String = record
167                        .txt_data()
168                        .iter()
169                        .map(|data| String::from_utf8_lossy(data))
170                        .collect();
171
172                    trace!(
173                        record = %record_name,
174                        found_value = %value,
175                        expected_value = %expected_value,
176                        "Checking TXT record"
177                    );
178
179                    if value == expected_value {
180                        return Ok(true);
181                    }
182                }
183                Ok(false)
184            }
185            Err(e) => {
186                // NXDOMAIN, NOERROR with no records, or SERVFAIL is expected during propagation
187                // Check if the error message indicates a common transient condition
188                let err_str = e.to_string().to_lowercase();
189                if err_str.contains("no records found")
190                    || err_str.contains("nxdomain")
191                    || err_str.contains("no connections available")
192                    || err_str.contains("record not found")
193                {
194                    Ok(false)
195                } else {
196                    Err(DnsProviderError::ApiRequest(format!(
197                        "DNS lookup failed for '{}': {}",
198                        record_name, e
199                    )))
200                }
201            }
202        }
203    }
204
205    /// Verify a record exists immediately (no waiting)
206    ///
207    /// Useful for testing or verifying cleanup.
208    pub async fn verify_record_exists(
209        &self,
210        domain: &str,
211        expected_value: &str,
212    ) -> Result<bool, DnsProviderError> {
213        let record_name = challenge_record_fqdn(domain);
214        self.check_record(&record_name, expected_value).await
215    }
216
217    /// Get the configuration
218    pub fn config(&self) -> &PropagationConfig {
219        &self.config
220    }
221}
222
223impl Default for PropagationChecker {
224    fn default() -> Self {
225        Self::new().expect("Failed to create default PropagationChecker")
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_default_config() {
235        let config = PropagationConfig::default();
236        assert_eq!(config.initial_delay, Duration::from_secs(10));
237        assert_eq!(config.check_interval, Duration::from_secs(5));
238        assert_eq!(config.timeout, Duration::from_secs(120));
239        assert!(!config.nameservers.is_empty());
240    }
241
242    #[tokio::test]
243    async fn test_propagation_checker_creation() {
244        let checker = PropagationChecker::new();
245        assert!(checker.is_ok());
246    }
247
248    #[tokio::test]
249    async fn test_custom_config() {
250        let config = PropagationConfig {
251            initial_delay: Duration::from_secs(5),
252            check_interval: Duration::from_secs(2),
253            timeout: Duration::from_secs(60),
254            nameservers: vec![IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))],
255        };
256
257        let checker = PropagationChecker::with_config(config.clone());
258        assert!(checker.is_ok());
259
260        let checker = checker.unwrap();
261        assert_eq!(checker.config().initial_delay, Duration::from_secs(5));
262    }
263}