postrust_proxy/saas/
verification.rs1use crate::saas::types::VerificationResult;
6use hickory_resolver::config::ResolverConfig;
7use hickory_resolver::net::runtime::TokioRuntimeProvider;
8use hickory_resolver::TokioResolver;
9use std::time::Duration;
10
11fn txt_matches(rendered: &str, expected: &str) -> bool {
19 if rendered == expected {
20 return true;
21 }
22 if rendered.trim().trim_matches('"').trim() == expected {
24 return true;
25 }
26 let joined: String = rendered
29 .split_whitespace()
30 .map(|part| part.trim_matches('"'))
31 .collect();
32 joined == expected
33}
34
35pub struct DomainVerificationService {
37 dns_resolver: TokioResolver,
38 http_client: reqwest::Client,
39}
40
41impl DomainVerificationService {
42 pub fn new() -> Self {
44 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 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 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 match self.dns_resolver.txt_lookup(&record_name).await {
87 Ok(response) => {
88 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 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 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 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 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 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 #[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 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 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 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 let result = service.verify_http("example.com", "testtoken123").await;
347
348 match result {
349 VerificationResult::Failed { .. } => {
350 }
352 _ => panic!("Expected verification to fail"),
353 }
354 }
355}