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, ResolverOpts};
7use hickory_resolver::TokioAsyncResolver;
8use std::time::Duration;
9
10/// Domain verification service for DNS and HTTP challenges.
11pub struct DomainVerificationService {
12    dns_resolver: TokioAsyncResolver,
13    http_client: reqwest::Client,
14}
15
16impl DomainVerificationService {
17    /// Create a new domain verification service.
18    pub fn new() -> Self {
19        // Create DNS resolver with system config
20        let dns_resolver =
21            TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
22
23        // Create HTTP client with reasonable timeouts
24        let http_client = reqwest::Client::builder()
25            .timeout(Duration::from_secs(10))
26            .connect_timeout(Duration::from_secs(5))
27            .redirect(reqwest::redirect::Policy::limited(3))
28            .user_agent("PostrustProxy/1.0 DomainVerification")
29            .build()
30            .expect("Failed to create HTTP client");
31
32        Self {
33            dns_resolver,
34            http_client,
35        }
36    }
37
38    /// Verify domain ownership via DNS TXT record.
39    ///
40    /// The user must create a TXT record at `_postrust-verification.{domain}`
41    /// with the value `postrust-verify={token}`.
42    pub async fn verify_dns(&self, domain: &str, token: &str) -> VerificationResult {
43        let record_name = format!("_postrust-verification.{}", domain);
44        let expected_value = format!("postrust-verify={}", token);
45
46        tracing::debug!(
47            record_name = %record_name,
48            "Performing DNS TXT verification"
49        );
50
51        // Perform DNS TXT lookup
52        match self.dns_resolver.txt_lookup(&record_name).await {
53            Ok(response) => {
54                // Check each TXT record
55                for record in response.iter() {
56                    let txt_data = record.to_string();
57                    tracing::debug!(txt_value = %txt_data, "Found TXT record");
58
59                    // TXT records may be quoted, so check both with and without quotes
60                    let txt_clean = txt_data.trim_matches('"').trim();
61
62                    if txt_clean == expected_value || txt_data == expected_value {
63                        tracing::info!(domain = %domain, "DNS verification successful");
64                        return VerificationResult::Verified;
65                    }
66                }
67
68                tracing::warn!(
69                    domain = %domain,
70                    expected = %expected_value,
71                    "DNS TXT record found but value mismatch"
72                );
73                VerificationResult::Failed {
74                    reason: "DNS TXT record found but value does not match".into(),
75                }
76            }
77            Err(e) => {
78                let error_kind = e.kind();
79                tracing::warn!(
80                    domain = %domain,
81                    error = %e,
82                    kind = ?error_kind,
83                    "DNS lookup failed"
84                );
85
86                // Provide helpful error messages based on error type
87                let reason = match error_kind {
88                    hickory_resolver::error::ResolveErrorKind::NoRecordsFound { .. } => {
89                        format!(
90                            "No TXT record found at {}. Please create a TXT record with value: {}",
91                            record_name, expected_value
92                        )
93                    }
94                    hickory_resolver::error::ResolveErrorKind::Timeout => {
95                        "DNS lookup timed out. Please try again later.".into()
96                    }
97                    _ => format!("DNS lookup failed: {}", e),
98                };
99
100                VerificationResult::Failed { reason }
101            }
102        }
103    }
104
105    /// Verify domain ownership via HTTP challenge.
106    ///
107    /// The user must serve a file at `https://{domain}/.well-known/postrust-verification/{token}`
108    /// with the content `postrust-verify={token}`.
109    pub async fn verify_http(&self, domain: &str, token: &str) -> VerificationResult {
110        let url = format!(
111            "https://{}/.well-known/postrust-verification/{}",
112            domain, token
113        );
114        let expected_content = format!("postrust-verify={}", token);
115
116        tracing::debug!(url = %url, "Performing HTTP verification");
117
118        // Try HTTPS first
119        match self.fetch_verification_content(&url).await {
120            Ok(content) => {
121                let content_trimmed = content.trim();
122                if content_trimmed == expected_content {
123                    tracing::info!(domain = %domain, "HTTP verification successful");
124                    VerificationResult::Verified
125                } else {
126                    tracing::warn!(
127                        domain = %domain,
128                        expected = %expected_content,
129                        actual = %content_trimmed,
130                        "HTTP content mismatch"
131                    );
132                    VerificationResult::Failed {
133                        reason: format!(
134                            "Content mismatch. Expected '{}' but got '{}'",
135                            expected_content, content_trimmed
136                        ),
137                    }
138                }
139            }
140            Err(e) => {
141                // Try HTTP as fallback (for domains not yet having SSL)
142                let http_url = format!(
143                    "http://{}/.well-known/postrust-verification/{}",
144                    domain, token
145                );
146
147                tracing::debug!(
148                    url = %http_url,
149                    "HTTPS failed, trying HTTP fallback"
150                );
151
152                match self.fetch_verification_content(&http_url).await {
153                    Ok(content) => {
154                        let content_trimmed = content.trim();
155                        if content_trimmed == expected_content {
156                            tracing::info!(
157                                domain = %domain,
158                                "HTTP verification successful (via HTTP fallback)"
159                            );
160                            VerificationResult::Verified
161                        } else {
162                            VerificationResult::Failed {
163                                reason: format!(
164                                    "Content mismatch. Expected '{}' but got '{}'",
165                                    expected_content, content_trimmed
166                                ),
167                            }
168                        }
169                    }
170                    Err(http_err) => {
171                        tracing::warn!(
172                            domain = %domain,
173                            https_error = %e,
174                            http_error = %http_err,
175                            "Both HTTPS and HTTP verification failed"
176                        );
177
178                        VerificationResult::Failed {
179                            reason: format!(
180                                "Failed to fetch verification file. HTTPS error: {}. HTTP error: {}. \
181                                 Please ensure the file is accessible at {}",
182                                e, http_err, url
183                            ),
184                        }
185                    }
186                }
187            }
188        }
189    }
190
191    /// Fetch content from a URL for verification.
192    async fn fetch_verification_content(&self, url: &str) -> Result<String, String> {
193        let response = self
194            .http_client
195            .get(url)
196            .send()
197            .await
198            .map_err(|e| format!("Request failed: {}", e))?;
199
200        let status = response.status();
201        if !status.is_success() {
202            return Err(format!("HTTP {} response", status));
203        }
204
205        let content_length = response.content_length().unwrap_or(0);
206        if content_length > 1024 {
207            return Err("Response too large (max 1KB)".into());
208        }
209
210        response
211            .text()
212            .await
213            .map_err(|e| format!("Failed to read response: {}", e))
214    }
215}
216
217impl Default for DomainVerificationService {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[tokio::test]
228    async fn test_dns_verification_not_found() {
229        let service = DomainVerificationService::new();
230
231        // Use a domain that definitely won't have our verification record
232        let result = service
233            .verify_dns("definitely-not-a-real-domain-12345.invalid", "testtoken123")
234            .await;
235
236        match result {
237            VerificationResult::Failed { reason } => {
238                assert!(reason.contains("No TXT record") || reason.contains("DNS lookup failed"));
239            }
240            _ => panic!("Expected verification to fail for non-existent domain"),
241        }
242    }
243
244    #[tokio::test]
245    async fn test_http_verification_not_found() {
246        let service = DomainVerificationService::new();
247
248        // Use a domain that won't have our verification file
249        let result = service.verify_http("example.com", "testtoken123").await;
250
251        match result {
252            VerificationResult::Failed { .. } => {
253                // Expected to fail
254            }
255            _ => panic!("Expected verification to fail"),
256        }
257    }
258}