postrust_proxy/saas/
verification.rs1use crate::saas::types::VerificationResult;
6use hickory_resolver::config::{ResolverConfig, ResolverOpts};
7use hickory_resolver::TokioAsyncResolver;
8use std::time::Duration;
9
10pub struct DomainVerificationService {
12 dns_resolver: TokioAsyncResolver,
13 http_client: reqwest::Client,
14}
15
16impl DomainVerificationService {
17 pub fn new() -> Self {
19 let dns_resolver =
21 TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
22
23 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 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 match self.dns_resolver.txt_lookup(&record_name).await {
53 Ok(response) => {
54 for record in response.iter() {
56 let txt_data = record.to_string();
57 tracing::debug!(txt_value = %txt_data, "Found TXT record");
58
59 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 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 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 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 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 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 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 let result = service.verify_http("example.com", "testtoken123").await;
250
251 match result {
252 VerificationResult::Failed { .. } => {
253 }
255 _ => panic!("Expected verification to fail"),
256 }
257 }
258}