Skip to main content

postrust_proxy/saas/
verification.rs

1//! Domain verification service.
2//!
3//! Provides DNS TXT and HTTP challenge verification for domain ownership.
4
5use crate::saas::types::VerificationResult;
6use hickory_resolver::config::ResolverConfig;
7use hickory_resolver::net::runtime::TokioRuntimeProvider;
8use hickory_resolver::TokioResolver;
9use std::time::Duration;
10
11/// Whether a TXT record's rendered rdata carries the expected challenge value.
12///
13/// Worth its own function because the rendering is not the raw value. A TXT
14/// record's rdata renders quoted (`"postrust-verify=abc"`), and a record with
15/// several character strings renders them space-separated and each quoted, so
16/// comparing the rendered form directly against a bare token fails on a record
17/// that is in fact correct.
18fn txt_matches(rendered: &str, expected: &str) -> bool {
19    if rendered == expected {
20        return true;
21    }
22    // A single quoted string, which is the ordinary case.
23    if rendered.trim().trim_matches('"').trim() == expected {
24        return true;
25    }
26    // A long value is split into several 255-byte character strings, each
27    // rendered quoted. DNS defines the value as their concatenation.
28    let joined: String = rendered
29        .split_whitespace()
30        .map(|part| part.trim_matches('"'))
31        .collect();
32    joined == expected
33}
34
35/// Domain verification service for DNS and HTTP challenges.
36pub struct DomainVerificationService {
37    dns_resolver: TokioResolver,
38    http_client: reqwest::Client,
39}
40
41impl DomainVerificationService {
42    /// Create a new domain verification service.
43    pub fn new() -> Self {
44        // Create DNS resolver with the default config.
45        //
46        // `builder_with_config` rather than `builder`: the latter reads
47        // /etc/resolv.conf, and a verification service should not refuse to
48        // construct because the host's resolver file is unreadable. The
49        // default config is what this used before hickory 0.26.
50        let dns_resolver = TokioResolver::builder_with_config(
51            ResolverConfig::default(),
52            TokioRuntimeProvider::default(),
53        )
54        .build()
55        .expect("Failed to create DNS resolver");
56
57        // Create HTTP client with reasonable timeouts
58        let http_client = reqwest::Client::builder()
59            .timeout(Duration::from_secs(10))
60            .connect_timeout(Duration::from_secs(5))
61            .redirect(reqwest::redirect::Policy::limited(3))
62            .user_agent("PostrustProxy/1.0 DomainVerification")
63            .build()
64            .expect("Failed to create HTTP client");
65
66        Self {
67            dns_resolver,
68            http_client,
69        }
70    }
71
72    /// Verify domain ownership via DNS TXT record.
73    ///
74    /// The user must create a TXT record at `_postrust-verification.{domain}`
75    /// with the value `postrust-verify={token}`.
76    pub async fn verify_dns(&self, domain: &str, token: &str) -> VerificationResult {
77        let record_name = format!("_postrust-verification.{}", domain);
78        let expected_value = format!("postrust-verify={}", token);
79
80        tracing::debug!(
81            record_name = %record_name,
82            "Performing DNS TXT verification"
83        );
84
85        // Perform DNS TXT lookup
86        match self.dns_resolver.txt_lookup(&record_name).await {
87            Ok(response) => {
88                // `answers()`, and `record.data()` rather than `record` --
89                // hickory 0.26 replaced the rdata iterator with a record
90                // slice, and `Record`'s Display renders the whole line
91                // (`name ttl class type rdata`), which would never match a
92                // bare token. Getting this wrong fails every verification.
93                for record in response.answers() {
94                    let txt_data = record.data.to_string();
95                    tracing::debug!(txt_value = %txt_data, "Found TXT record");
96
97                    if txt_matches(&txt_data, &expected_value) {
98                        tracing::info!(domain = %domain, "DNS verification successful");
99                        return VerificationResult::Verified;
100                    }
101                }
102
103                tracing::warn!(
104                    domain = %domain,
105                    expected = %expected_value,
106                    "DNS TXT record found but value mismatch"
107                );
108                VerificationResult::Failed {
109                    reason: "DNS TXT record found but value does not match".into(),
110                }
111            }
112            Err(e) => {
113                tracing::warn!(
114                    domain = %domain,
115                    error = %e,
116                    "DNS lookup failed"
117                );
118
119                // Provide helpful error messages based on error type.
120                //
121                // hickory 0.26 moved the taxonomy: what was
122                // `ResolveErrorKind::NoRecordsFound` is now reached through
123                // `NetError::is_no_records_found`, and `Timeout` is a variant
124                // of `NetError` rather than of the resolver's own error.
125                let reason = if e.is_no_records_found() {
126                    format!(
127                        "No TXT record found at {}. Please create a TXT record with value: {}",
128                        record_name, expected_value
129                    )
130                } else if matches!(e, hickory_resolver::net::NetError::Timeout) {
131                    "DNS lookup timed out. Please try again later.".to_string()
132                } else {
133                    format!("DNS lookup failed: {}", e)
134                };
135
136                VerificationResult::Failed { reason }
137            }
138        }
139    }
140
141    /// Verify domain ownership via HTTP challenge.
142    ///
143    /// The user must serve a file at `https://{domain}/.well-known/postrust-verification/{token}`
144    /// with the content `postrust-verify={token}`.
145    pub async fn verify_http(&self, domain: &str, token: &str) -> VerificationResult {
146        let url = format!(
147            "https://{}/.well-known/postrust-verification/{}",
148            domain, token
149        );
150        let expected_content = format!("postrust-verify={}", token);
151
152        tracing::debug!(url = %url, "Performing HTTP verification");
153
154        // Try HTTPS first
155        match self.fetch_verification_content(&url).await {
156            Ok(content) => {
157                let content_trimmed = content.trim();
158                if content_trimmed == expected_content {
159                    tracing::info!(domain = %domain, "HTTP verification successful");
160                    VerificationResult::Verified
161                } else {
162                    tracing::warn!(
163                        domain = %domain,
164                        expected = %expected_content,
165                        actual = %content_trimmed,
166                        "HTTP content mismatch"
167                    );
168                    VerificationResult::Failed {
169                        reason: format!(
170                            "Content mismatch. Expected '{}' but got '{}'",
171                            expected_content, content_trimmed
172                        ),
173                    }
174                }
175            }
176            Err(e) => {
177                // Try HTTP as fallback (for domains not yet having SSL)
178                let http_url = format!(
179                    "http://{}/.well-known/postrust-verification/{}",
180                    domain, token
181                );
182
183                tracing::debug!(
184                    url = %http_url,
185                    "HTTPS failed, trying HTTP fallback"
186                );
187
188                match self.fetch_verification_content(&http_url).await {
189                    Ok(content) => {
190                        let content_trimmed = content.trim();
191                        if content_trimmed == expected_content {
192                            tracing::info!(
193                                domain = %domain,
194                                "HTTP verification successful (via HTTP fallback)"
195                            );
196                            VerificationResult::Verified
197                        } else {
198                            VerificationResult::Failed {
199                                reason: format!(
200                                    "Content mismatch. Expected '{}' but got '{}'",
201                                    expected_content, content_trimmed
202                                ),
203                            }
204                        }
205                    }
206                    Err(http_err) => {
207                        tracing::warn!(
208                            domain = %domain,
209                            https_error = %e,
210                            http_error = %http_err,
211                            "Both HTTPS and HTTP verification failed"
212                        );
213
214                        VerificationResult::Failed {
215                            reason: format!(
216                                "Failed to fetch verification file. HTTPS error: {}. HTTP error: {}. \
217                                 Please ensure the file is accessible at {}",
218                                e, http_err, url
219                            ),
220                        }
221                    }
222                }
223            }
224        }
225    }
226
227    /// Fetch content from a URL for verification.
228    async fn fetch_verification_content(&self, url: &str) -> Result<String, String> {
229        let response = self
230            .http_client
231            .get(url)
232            .send()
233            .await
234            .map_err(|e| format!("Request failed: {}", e))?;
235
236        let status = response.status();
237        if !status.is_success() {
238            return Err(format!("HTTP {} response", status));
239        }
240
241        let content_length = response.content_length().unwrap_or(0);
242        if content_length > 1024 {
243            return Err("Response too large (max 1KB)".into());
244        }
245
246        response
247            .text()
248            .await
249            .map_err(|e| format!("Failed to read response: {}", e))
250    }
251}
252
253impl Default for DomainVerificationService {
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    // These cover the comparison that the hickory 0.26 migration moved. The
264    // old code iterated rdata and stringified it; the new code takes
265    // `record.data` off a `Record`, because `Record`'s own Display renders
266    // `name ttl class type rdata` and would never match a bare token. A
267    // regression here fails every DNS verification silently.
268
269    #[test]
270    fn a_quoted_txt_value_matches() {
271        assert!(txt_matches(
272            "\"postrust-verify=abc123\"",
273            "postrust-verify=abc123"
274        ));
275    }
276
277    #[test]
278    fn an_unquoted_txt_value_matches() {
279        assert!(txt_matches(
280            "postrust-verify=abc123",
281            "postrust-verify=abc123"
282        ));
283    }
284
285    #[test]
286    fn surrounding_whitespace_does_not_break_it() {
287        assert!(txt_matches(
288            "  \"postrust-verify=abc123\"  ",
289            "postrust-verify=abc123"
290        ));
291    }
292
293    #[test]
294    fn a_value_split_across_character_strings_matches() {
295        // A TXT value over 255 bytes is stored as several character strings
296        // and renders as several quoted parts; DNS defines the value as their
297        // concatenation.
298        assert!(txt_matches(
299            "\"postrust-verify=\" \"abc123\"",
300            "postrust-verify=abc123"
301        ));
302    }
303
304    #[test]
305    fn a_different_value_does_not_match() {
306        assert!(!txt_matches(
307            "\"postrust-verify=wrong\"",
308            "postrust-verify=abc123"
309        ));
310        assert!(!txt_matches("\"\"", "postrust-verify=abc123"));
311        assert!(!txt_matches("", "postrust-verify=abc123"));
312    }
313
314    #[test]
315    fn a_whole_record_line_does_not_match() {
316        // Exactly what `record.to_string()` would have produced. If this ever
317        // starts passing, the comparison has been loosened too far.
318        assert!(!txt_matches(
319            "_postrust-verification.example.com. 300 IN TXT \"postrust-verify=abc123\"",
320            "postrust-verify=abc123"
321        ));
322    }
323
324    #[tokio::test]
325    async fn test_dns_verification_not_found() {
326        let service = DomainVerificationService::new();
327
328        // Use a domain that definitely won't have our verification record
329        let result = service
330            .verify_dns("definitely-not-a-real-domain-12345.invalid", "testtoken123")
331            .await;
332
333        match result {
334            VerificationResult::Failed { reason } => {
335                assert!(reason.contains("No TXT record") || reason.contains("DNS lookup failed"));
336            }
337            _ => panic!("Expected verification to fail for non-existent domain"),
338        }
339    }
340
341    #[tokio::test]
342    async fn test_http_verification_not_found() {
343        let service = DomainVerificationService::new();
344
345        // Use a domain that won't have our verification file
346        let result = service.verify_http("example.com", "testtoken123").await;
347
348        match result {
349            VerificationResult::Failed { .. } => {
350                // Expected to fail
351            }
352            _ => panic!("Expected verification to fail"),
353        }
354    }
355}