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 = Resolver::builder_with_config(resolver_config, TokioConnectionProvider::default())
90            .with_options(opts)
91            .build();
92        Ok(resolver)
93    }
94
95    /// Wait for a TXT record to propagate
96    ///
97    /// # Arguments
98    ///
99    /// * `domain` - The domain for the challenge
100    /// * `expected_value` - The expected TXT record value
101    ///
102    /// # Returns
103    ///
104    /// `Ok(())` when the record is found with the expected value,
105    /// or an error if the timeout is reached.
106    pub async fn wait_for_propagation(
107        &self,
108        domain: &str,
109        expected_value: &str,
110    ) -> Result<(), DnsProviderError> {
111        let record_name = challenge_record_fqdn(domain);
112        let start = Instant::now();
113        let deadline = start + self.config.timeout;
114
115        debug!(
116            record = %record_name,
117            timeout_secs = self.config.timeout.as_secs(),
118            "Waiting for DNS propagation"
119        );
120
121        // Initial delay
122        tokio::time::sleep(self.config.initial_delay).await;
123
124        loop {
125            match self.check_record(&record_name, expected_value).await {
126                Ok(true) => {
127                    let elapsed = start.elapsed();
128                    debug!(
129                        record = %record_name,
130                        elapsed_secs = elapsed.as_secs(),
131                        "DNS propagation confirmed"
132                    );
133                    return Ok(());
134                }
135                Ok(false) => {
136                    trace!(record = %record_name, "Record not yet propagated");
137                }
138                Err(e) => {
139                    warn!(record = %record_name, error = %e, "DNS lookup error");
140                }
141            }
142
143            if Instant::now() > deadline {
144                return Err(DnsProviderError::Timeout {
145                    elapsed_secs: self.config.timeout.as_secs(),
146                });
147            }
148
149            tokio::time::sleep(self.config.check_interval).await;
150        }
151    }
152
153    /// Check if a TXT record exists with the expected value
154    async fn check_record(&self, record_name: &str, expected_value: &str) -> Result<bool, DnsProviderError> {
155        let lookup = self.resolver.txt_lookup(record_name).await;
156
157        match lookup {
158            Ok(records) => {
159                for record in records.iter() {
160                    // TXT records can have multiple strings, join them
161                    let value: String = record.txt_data().iter()
162                        .map(|data| String::from_utf8_lossy(data))
163                        .collect();
164
165                    trace!(
166                        record = %record_name,
167                        found_value = %value,
168                        expected_value = %expected_value,
169                        "Checking TXT record"
170                    );
171
172                    if value == expected_value {
173                        return Ok(true);
174                    }
175                }
176                Ok(false)
177            }
178            Err(e) => {
179                // NXDOMAIN, NOERROR with no records, or SERVFAIL is expected during propagation
180                // Check if the error message indicates a common transient condition
181                let err_str = e.to_string().to_lowercase();
182                if err_str.contains("no records found")
183                    || err_str.contains("nxdomain")
184                    || err_str.contains("no connections available")
185                    || err_str.contains("record not found")
186                {
187                    Ok(false)
188                } else {
189                    Err(DnsProviderError::ApiRequest(format!(
190                        "DNS lookup failed for '{}': {}",
191                        record_name, e
192                    )))
193                }
194            }
195        }
196    }
197
198    /// Verify a record exists immediately (no waiting)
199    ///
200    /// Useful for testing or verifying cleanup.
201    pub async fn verify_record_exists(
202        &self,
203        domain: &str,
204        expected_value: &str,
205    ) -> Result<bool, DnsProviderError> {
206        let record_name = challenge_record_fqdn(domain);
207        self.check_record(&record_name, expected_value).await
208    }
209
210    /// Get the configuration
211    pub fn config(&self) -> &PropagationConfig {
212        &self.config
213    }
214}
215
216impl Default for PropagationChecker {
217    fn default() -> Self {
218        Self::new().expect("Failed to create default PropagationChecker")
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_default_config() {
228        let config = PropagationConfig::default();
229        assert_eq!(config.initial_delay, Duration::from_secs(10));
230        assert_eq!(config.check_interval, Duration::from_secs(5));
231        assert_eq!(config.timeout, Duration::from_secs(120));
232        assert!(!config.nameservers.is_empty());
233    }
234
235    #[tokio::test]
236    async fn test_propagation_checker_creation() {
237        let checker = PropagationChecker::new();
238        assert!(checker.is_ok());
239    }
240
241    #[tokio::test]
242    async fn test_custom_config() {
243        let config = PropagationConfig {
244            initial_delay: Duration::from_secs(5),
245            check_interval: Duration::from_secs(2),
246            timeout: Duration::from_secs(60),
247            nameservers: vec![IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))],
248        };
249
250        let checker = PropagationChecker::with_config(config.clone());
251        assert!(checker.is_ok());
252
253        let checker = checker.unwrap();
254        assert_eq!(checker.config().initial_delay, Duration::from_secs(5));
255    }
256}