1use std::future::Future;
15use std::pin::Pin;
16
17use chrono::Utc;
18
19use crate::control_db::ControlDb;
20use crate::domains::{DomainId, DomainStatus};
21use crate::error::SaasError;
22use crate::tenants::TenantId;
23
24pub type DnsFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, DnsError>> + Send + 'a>>;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum DnsError {
35 NotFound,
37 Timeout,
39 Other(String),
41}
42
43impl std::fmt::Display for DnsError {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 DnsError::NotFound => write!(f, "no DNS records found"),
47 DnsError::Timeout => write!(f, "DNS query timed out"),
48 DnsError::Other(msg) => write!(f, "DNS error: {msg}"),
49 }
50 }
51}
52
53pub trait DnsResolver: Send + Sync {
61 fn lookup_cname_chain<'a>(&'a self, host: &'a str) -> DnsFuture<'a, Vec<String>>;
62}
63
64pub struct HickoryDnsResolver {
68 inner: hickory_resolver::TokioResolver,
69}
70
71impl HickoryDnsResolver {
72 pub fn new() -> Result<Self, SaasError> {
74 use hickory_resolver::Resolver;
75 use hickory_resolver::config::ResolverConfig;
76 use hickory_resolver::net::runtime::TokioRuntimeProvider;
77
78 let resolver = Resolver::builder_with_config(
79 ResolverConfig::default(),
80 TokioRuntimeProvider::default(),
81 )
82 .build()
83 .map_err(|e| SaasError::Dns(format!("failed to build DNS resolver: {e}")))?;
84 Ok(Self { inner: resolver })
85 }
86}
87
88impl DnsResolver for HickoryDnsResolver {
89 fn lookup_cname_chain<'a>(&'a self, host: &'a str) -> DnsFuture<'a, Vec<String>> {
90 Box::pin(async move {
91 use hickory_resolver::proto::rr::{RData, RecordType};
92
93 const HOP_CAP: usize = 8;
94 let mut chain = Vec::new();
95 let mut current = host.to_owned();
96
97 for _ in 0..HOP_CAP {
98 match self.inner.lookup(current.as_str(), RecordType::CNAME).await {
99 Ok(lookup) => {
100 let target = lookup.answers().iter().find_map(|record| {
102 if let RData::CNAME(cname) = &record.data {
103 let name = cname.0.to_string();
104 Some(name.trim_end_matches('.').to_lowercase())
106 } else {
107 None
108 }
109 });
110 match target {
111 Some(t) => {
112 chain.push(t.clone());
113 current = t;
114 }
115 None => break,
117 }
118 }
119 Err(e) if e.is_no_records_found() => {
120 break;
123 }
124 Err(e) => {
125 use hickory_resolver::net::NetError;
127 let dns_err = match e {
128 NetError::Timeout => DnsError::Timeout,
129 other => DnsError::Other(other.to_string()),
130 };
131 return Err(dns_err);
132 }
133 }
134 }
135
136 Ok(chain)
137 })
138 }
139}
140
141#[cfg(any(test, feature = "test-util"))]
152pub struct MockDnsResolver {
153 chains: tokio::sync::Mutex<
154 std::collections::HashMap<
155 String,
156 std::collections::VecDeque<Result<Vec<String>, DnsError>>,
157 >,
158 >,
159}
160
161#[cfg(any(test, feature = "test-util"))]
162impl MockDnsResolver {
163 pub fn new() -> Self {
164 Self {
165 chains: tokio::sync::Mutex::new(std::collections::HashMap::new()),
166 }
167 }
168
169 pub async fn queue(&self, host: &str, response: Result<Vec<String>, DnsError>) {
171 self.chains
172 .lock()
173 .await
174 .entry(host.to_owned())
175 .or_default()
176 .push_back(response);
177 }
178}
179
180#[cfg(any(test, feature = "test-util"))]
181impl DnsResolver for MockDnsResolver {
182 fn lookup_cname_chain<'a>(&'a self, host: &'a str) -> DnsFuture<'a, Vec<String>> {
183 Box::pin(async move {
184 let mut guard = self.chains.lock().await;
185 guard
186 .get_mut(host)
187 .and_then(|q| q.pop_front())
188 .unwrap_or(Ok(vec![]))
189 })
190 }
191}
192
193pub async fn verify_domain(
206 resolver: &dyn DnsResolver,
207 control_db: &ControlDb,
208 domain_id: DomainId,
209 _tenant_id: TenantId,
210 domain: &str,
211 dns_target: &str,
212) -> Result<DomainStatus, SaasError> {
213 let target_lower = dns_target.to_lowercase();
214
215 match resolver.lookup_cname_chain(domain).await {
216 Ok(chain) => {
217 let found = chain.iter().any(|hop| {
220 let hop_stripped = hop.trim_end_matches('.');
221 hop_stripped.eq_ignore_ascii_case(&target_lower)
222 });
223
224 if found {
225 control_db
226 .set_tenant_domain_status(
227 &domain_id,
228 DomainStatus::Verified,
229 Some(Utc::now()),
230 None,
231 )
232 .await?;
233 Ok(DomainStatus::Verified)
234 } else {
235 let msg = format!("CNAME chain {chain:?} does not contain {dns_target}");
236 control_db
237 .set_tenant_domain_status(
238 &domain_id,
239 DomainStatus::Failed,
240 None,
241 Some(msg.as_str()),
242 )
243 .await?;
244 Ok(DomainStatus::Failed)
245 }
246 }
247 Err(DnsError::NotFound) => {
248 control_db
249 .set_tenant_domain_status(
250 &domain_id,
251 DomainStatus::Failed,
252 None,
253 Some("no CNAME record found"),
254 )
255 .await?;
256 Ok(DomainStatus::Failed)
257 }
258 Err(e) => {
259 let msg = e.to_string();
260 control_db
261 .set_tenant_domain_status(
262 &domain_id,
263 DomainStatus::Failed,
264 None,
265 Some(msg.as_str()),
266 )
267 .await?;
268 Ok(DomainStatus::Failed)
269 }
270 }
271}
272
273#[derive(Debug, Default)]
277pub struct SweepStats {
278 pub processed: usize,
279 pub verified: usize,
280 pub failed: usize,
281}
282
283pub async fn run_one_sweep(
286 resolver: &dyn DnsResolver,
287 control_db: &ControlDb,
288 limit: usize,
289) -> Result<SweepStats, SaasError> {
290 use crate::domains::DomainId as Did;
291 use uuid::Uuid;
292
293 let rows = control_db.list_domains_for_sweep(limit as i64).await?;
294 let mut stats = SweepStats::default();
295
296 for row in &rows {
297 let domain_id = match Uuid::from_slice(&row.id) {
298 Ok(u) => Did::from(u),
299 Err(_) => {
300 tracing::warn!("skipping domain row with undecodable UUID in sweep");
301 continue;
302 }
303 };
304 let tenant_id = match Uuid::from_slice(&row.tenant_id) {
305 Ok(u) => TenantId::from(u),
306 Err(_) => {
307 tracing::warn!("skipping domain row with undecodable tenant UUID in sweep");
308 continue;
309 }
310 };
311
312 stats.processed += 1;
313 match verify_domain(
314 resolver,
315 control_db,
316 domain_id,
317 tenant_id,
318 &row.domain,
319 &row.dns_target,
320 )
321 .await
322 {
323 Ok(DomainStatus::Verified) => stats.verified += 1,
324 Ok(_) => stats.failed += 1,
325 Err(e) => {
326 tracing::warn!(domain = %row.domain, error = %e, "sweep: DB error on domain");
327 stats.failed += 1;
328 }
329 }
330 }
331
332 Ok(stats)
333}
334
335#[cfg(test)]
338mod tests {
339 use super::*;
340 use crate::control_db::ControlDb;
341 use crate::control_db::tests::test_pool;
342 use crate::domains::DomainId;
343 use uuid::Uuid;
344
345 async fn setup(slug: &str, domain: &str) -> (ControlDb, DomainId, TenantId) {
348 let pool = test_pool().await;
349 let db = ControlDb::new(pool).await.unwrap();
350
351 let plan_id: Vec<u8> = sqlx::query_scalar("SELECT id FROM tenant_plans LIMIT 1")
353 .fetch_one(db.pool())
354 .await
355 .unwrap();
356 let tid_uuid = Uuid::now_v7();
357 sqlx::query(
358 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
359 VALUES (?, ?, ?, ?, ?, 'active', ?)",
360 )
361 .bind(tid_uuid.as_bytes().as_ref())
362 .bind(slug)
363 .bind(slug)
364 .bind(format!("{slug}@example.com"))
365 .bind(&plan_id)
366 .bind(format!("{slug}.db"))
367 .execute(db.pool())
368 .await
369 .unwrap();
370
371 let tenant_id = TenantId::from(tid_uuid);
372 let row = db
373 .create_tenant_domain(&tenant_id, domain, "custom.allowthem.io")
374 .await
375 .unwrap();
376 let domain_id = DomainId::from(Uuid::from_slice(&row.id).unwrap());
377
378 (db, domain_id, tenant_id)
379 }
380
381 #[tokio::test]
384 async fn verify_domain_marks_verified_when_chain_contains_target() {
385 let (db, domain_id, tenant_id) = setup("vf-a", "auth.example.com").await;
386 let resolver = MockDnsResolver::new();
387 resolver
388 .queue("auth.example.com", Ok(vec!["custom.allowthem.io".into()]))
389 .await;
390
391 let status = verify_domain(
392 &resolver,
393 &db,
394 domain_id,
395 tenant_id,
396 "auth.example.com",
397 "custom.allowthem.io",
398 )
399 .await
400 .unwrap();
401
402 assert_eq!(status, DomainStatus::Verified);
403 let row = db
404 .get_tenant_domain_scoped(&domain_id, &tenant_id)
405 .await
406 .unwrap()
407 .unwrap();
408 assert_eq!(row.status, DomainStatus::Verified);
409 assert!(row.verified_at.is_some());
410 assert!(row.last_error.is_none());
411 }
412
413 #[tokio::test]
414 async fn verify_domain_marks_verified_when_target_has_trailing_dot() {
415 let (db, domain_id, tenant_id) = setup("vf-b", "auth.example.com").await;
416 let resolver = MockDnsResolver::new();
417 resolver
419 .queue("auth.example.com", Ok(vec!["custom.allowthem.io.".into()]))
420 .await;
421
422 let status = verify_domain(
423 &resolver,
424 &db,
425 domain_id,
426 tenant_id,
427 "auth.example.com",
428 "custom.allowthem.io",
429 )
430 .await
431 .unwrap();
432
433 assert_eq!(status, DomainStatus::Verified);
434 }
435
436 #[tokio::test]
437 async fn verify_domain_marks_verified_when_target_is_not_last_hop() {
438 let (db, domain_id, tenant_id) = setup("vf-c", "auth.example.com").await;
440 let resolver = MockDnsResolver::new();
441 resolver
442 .queue(
443 "auth.example.com",
444 Ok(vec![
445 "custom.allowthem.io".into(),
446 "cdn-edge.fastly.net".into(),
447 ]),
448 )
449 .await;
450
451 let status = verify_domain(
452 &resolver,
453 &db,
454 domain_id,
455 tenant_id,
456 "auth.example.com",
457 "custom.allowthem.io",
458 )
459 .await
460 .unwrap();
461
462 assert_eq!(status, DomainStatus::Verified);
463 }
464
465 #[tokio::test]
466 async fn verify_domain_marks_failed_when_no_cname() {
467 let (db, domain_id, tenant_id) = setup("vf-d", "auth.example.com").await;
468 let resolver = MockDnsResolver::new();
469 resolver
470 .queue("auth.example.com", Err(DnsError::NotFound))
471 .await;
472
473 let status = verify_domain(
474 &resolver,
475 &db,
476 domain_id,
477 tenant_id,
478 "auth.example.com",
479 "custom.allowthem.io",
480 )
481 .await
482 .unwrap();
483
484 assert_eq!(status, DomainStatus::Failed);
485 let row = db
486 .get_tenant_domain_scoped(&domain_id, &tenant_id)
487 .await
488 .unwrap()
489 .unwrap();
490 assert_eq!(row.status, DomainStatus::Failed);
491 assert!(row.last_error.as_deref().unwrap().contains("no CNAME"));
492 }
493
494 #[tokio::test]
495 async fn verify_domain_marks_failed_when_chain_has_no_matching_hop() {
496 let (db, domain_id, tenant_id) = setup("vf-e", "auth.example.com").await;
497 let resolver = MockDnsResolver::new();
498 resolver
499 .queue(
500 "auth.example.com",
501 Ok(vec!["other-provider.example.net".into()]),
502 )
503 .await;
504
505 let status = verify_domain(
506 &resolver,
507 &db,
508 domain_id,
509 tenant_id,
510 "auth.example.com",
511 "custom.allowthem.io",
512 )
513 .await
514 .unwrap();
515
516 assert_eq!(status, DomainStatus::Failed);
517 let row = db
518 .get_tenant_domain_scoped(&domain_id, &tenant_id)
519 .await
520 .unwrap()
521 .unwrap();
522 assert!(
523 row.last_error
524 .as_deref()
525 .unwrap()
526 .contains("does not contain")
527 );
528 }
529
530 #[tokio::test]
531 async fn verify_domain_propagates_db_errors_through_saas_error() {
532 let (db, domain_id, tenant_id) = setup("vf-f", "auth.example.com").await;
534 db.pool().close().await;
535
536 let resolver = MockDnsResolver::new();
537 resolver
538 .queue("auth.example.com", Ok(vec!["custom.allowthem.io".into()]))
539 .await;
540
541 let result = verify_domain(
542 &resolver,
543 &db,
544 domain_id,
545 tenant_id,
546 "auth.example.com",
547 "custom.allowthem.io",
548 )
549 .await;
550
551 assert!(result.is_err(), "expected DB error to propagate");
552 }
553
554 #[tokio::test]
557 async fn lookup_cname_chain_caps_at_8_hops() {
558 let resolver = MockDnsResolver::new();
561 for i in 0..9_usize {
562 let host = if i == 0 {
563 "start.example.com".to_owned()
564 } else {
565 format!("hop{i}.example.com")
566 };
567 let next = format!("hop{}.example.com", i + 1);
568 resolver.queue(&host, Ok(vec![next])).await;
569 }
570
571 let chain = resolver
572 .lookup_cname_chain("start.example.com")
573 .await
574 .unwrap();
575 assert!(
578 chain.len() <= 8,
579 "chain length {} exceeds hop cap",
580 chain.len()
581 );
582 }
583
584 #[tokio::test]
587 async fn sweep_processes_pending_then_verified_disappears_from_pool() {
588 let (db, domain_id, tenant_id) = setup("sw-a", "auth.example.com").await;
589 let resolver = std::sync::Arc::new(MockDnsResolver::new());
590 resolver
591 .queue("auth.example.com", Ok(vec!["custom.allowthem.io".into()]))
592 .await;
593
594 let stats = run_one_sweep(&*resolver, &db, 100).await.unwrap();
595 assert_eq!(stats.verified, 1);
596 assert_eq!(stats.processed, 1);
597
598 let _ = (domain_id, tenant_id); let stats2 = run_one_sweep(&*resolver, &db, 100).await.unwrap();
601 assert_eq!(stats2.processed, 0);
602 }
603
604 #[tokio::test]
605 async fn sweep_caps_at_limit() {
606 let pool = test_pool().await;
607 let db = ControlDb::new(pool).await.unwrap();
608 let plan_id: Vec<u8> = sqlx::query_scalar("SELECT id FROM tenant_plans LIMIT 1")
609 .fetch_one(db.pool())
610 .await
611 .unwrap();
612
613 let tid_uuid = Uuid::now_v7();
615 sqlx::query(
616 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
617 VALUES (?, 'Cap', 'sw-cap', 'cap@example.com', ?, 'active', 'cap.db')",
618 )
619 .bind(tid_uuid.as_bytes().as_ref())
620 .bind(&plan_id)
621 .execute(db.pool())
622 .await
623 .unwrap();
624 let tenant_id = TenantId::from(tid_uuid);
625
626 for i in 0..5 {
627 db.create_tenant_domain(
628 &tenant_id,
629 &format!("d{i}.example.com"),
630 "custom.allowthem.io",
631 )
632 .await
633 .unwrap();
634 }
635
636 let resolver = MockDnsResolver::new();
637 let stats = run_one_sweep(&resolver, &db, 3).await.unwrap();
638 assert_eq!(stats.processed, 3, "sweep should respect limit");
639 }
640
641 #[tokio::test]
642 async fn sweep_failed_row_can_be_reprocessed_in_next_sweep() {
643 let (db, domain_id, tenant_id) = setup("sw-b", "auth.example.com").await;
644 let resolver = MockDnsResolver::new();
645
646 resolver
648 .queue("auth.example.com", Ok(vec!["wrong.example.net".into()]))
649 .await;
650 let s1 = run_one_sweep(&resolver, &db, 100).await.unwrap();
651 assert_eq!(s1.failed, 1);
652
653 resolver
655 .queue("auth.example.com", Ok(vec!["custom.allowthem.io".into()]))
656 .await;
657 let s2 = run_one_sweep(&resolver, &db, 100).await.unwrap();
658 assert_eq!(s2.verified, 1);
659
660 let row = db
661 .get_tenant_domain_scoped(&domain_id, &tenant_id)
662 .await
663 .unwrap()
664 .unwrap();
665 assert_eq!(row.status, DomainStatus::Verified);
666 }
667}