Skip to main content

allowthem_saas/
dns.rs

1//! DNS resolver trait, Hickory-backed real implementation, and `verify_domain`
2//! helper.
3//!
4//! ## Design notes
5//!
6//! - No `async-trait` — follow the `AuthFuture` / `DnsFuture` pattern used
7//!   by `EmailSender` and `SocialProvider` in `crates/core`.
8//! - `MockDnsResolver` is gated with `#[cfg(any(test, feature = "test-util"))]`
9//!   so it compiles only in tests and when the `test-util` Cargo feature is
10//!   enabled (needed by `binaries/saas` integration tests in Task 4).
11//! - `DomainStatus::Active` is intentionally never written here (38y.3 owns
12//!   the `Verified → Active` transition).
13
14use 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
24// ── DnsFuture type alias ──────────────────────────────────────────────────────
25
26/// Boxed future type alias mirroring the `AuthFuture` pattern in `crates/core`.
27pub type DnsFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, DnsError>> + Send + 'a>>;
28
29// ── DnsError ──────────────────────────────────────────────────────────────────
30
31/// DNS resolver errors. Mapped to [`SaasError::Dns`] at the `verify_domain`
32/// boundary so that `SaasError`'s surface stays tight.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum DnsError {
35    /// The queried name does not exist in DNS (NXDOMAIN or no records found).
36    NotFound,
37    /// The resolver timed out.
38    Timeout,
39    /// Any other resolver failure (parse error, I/O error, etc.).
40    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
53// ── DnsResolver trait ─────────────────────────────────────────────────────────
54
55/// Abstracts DNS CNAME resolution for testability.
56///
57/// Returns the CNAME chain rooted at `host`. An empty `Vec` signals that `host`
58/// is an A/AAAA terminus (no CNAME at that name). Errors signal resolver
59/// failures (timeout, parse errors, NXDOMAIN).
60pub trait DnsResolver: Send + Sync {
61    fn lookup_cname_chain<'a>(&'a self, host: &'a str) -> DnsFuture<'a, Vec<String>>;
62}
63
64// ── HickoryDnsResolver ────────────────────────────────────────────────────────
65
66/// Real `DnsResolver` backed by `hickory-resolver`.
67pub struct HickoryDnsResolver {
68    inner: hickory_resolver::TokioResolver,
69}
70
71impl HickoryDnsResolver {
72    /// Build a resolver using system defaults.
73    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                        // Extract the first CNAME target from the answer section.
101                        let target = lookup.answers().iter().find_map(|record| {
102                            if let RData::CNAME(cname) = &record.data {
103                                let name = cname.0.to_string();
104                                // Strip trailing FQDN dot so comparisons are clean.
105                                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                            // Response with no CNAME records = A/AAAA terminus.
116                            None => break,
117                        }
118                    }
119                    Err(e) if e.is_no_records_found() => {
120                        // NXDOMAIN or no records of the queried type — chain
121                        // terminated. Return what we have (possibly empty).
122                        break;
123                    }
124                    Err(e) => {
125                        // Classify the resolver error.
126                        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// ── MockDnsResolver ───────────────────────────────────────────────────────────
142
143/// Controllable `DnsResolver` for tests.
144///
145/// Each call to `lookup_cname_chain` pops one pre-queued response from the
146/// `VecDeque` for that host, allowing tests to model sequences such as
147/// "first attempt times out, second returns the correct chain".
148///
149/// Gated with `#[cfg(any(test, feature = "test-util"))]` so the type does
150/// not leak into production builds.
151#[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    /// Queue a response for the given host. Responses are consumed in order.
170    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
193// ── verify_domain helper ──────────────────────────────────────────────────────
194
195/// Verify that `domain`'s CNAME chain contains `dns_target`, updating the
196/// `tenant_domains` row accordingly.
197///
198/// - On success (`dns_target` appears anywhere in the chain): status → `Verified`.
199/// - On failure (no match or DNS error): status → `Failed`, `last_error` set.
200/// - DB write failures bubble as `SaasError`; DNS errors are swallowed into
201///   `Failed`.
202///
203/// `_tenant_id` is carried for caller context and future audit logging; the
204/// status update itself is addressed by `domain_id` alone.
205pub 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            // Check whether dns_target appears anywhere in the chain (§3.6:
218            // use `contains`, not `last()`, to survive CDN extra hops).
219            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// ── Background sweep helper ───────────────────────────────────────────────────
274
275/// Statistics returned by one sweep run.
276#[derive(Debug, Default)]
277pub struct SweepStats {
278    pub processed: usize,
279    pub verified: usize,
280    pub failed: usize,
281}
282
283/// Run one verification sweep over `pending_verification` and `failed` rows,
284/// capped at `limit` rows (oldest-first). Reuses [`verify_domain`].
285pub 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// ── Tests ─────────────────────────────────────────────────────────────────────
336
337#[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    // ─── Helper: build a seeded DB with one pending domain ───────────────────
346
347    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        // Seed a tenant.
352        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    // ─── verify_domain tests ─────────────────────────────────────────────────
382
383    #[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        // Chain hop ends with a trailing dot (FQDN).
418        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        // Target appears mid-chain (CDN adds more hops after it).
439        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        // Force a DB write failure by closing the pool.
533        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    // ─── MockDnsResolver hop-cap test ────────────────────────────────────────
555
556    #[tokio::test]
557    async fn lookup_cname_chain_caps_at_8_hops() {
558        // Queue 9 hops — MockDnsResolver will return them one at a time.
559        // The resolver should stop at 8.
560        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        // MockDnsResolver always pops one entry per call. With the cap at 8,
576        // "start" + hops 1..8 → 8 entries in the chain.
577        assert!(
578            chain.len() <= 8,
579            "chain length {} exceeds hop cap",
580            chain.len()
581        );
582    }
583
584    // ─── run_one_sweep tests ─────────────────────────────────────────────────
585
586    #[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        // Row should now be Verified, so a second sweep sees nothing.
599        let _ = (domain_id, tenant_id); // used to seed
600        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        // Seed 5 domains for one tenant.
614        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        // First sweep: wrong chain → Failed.
647        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        // Second sweep: correct chain → Verified.
654        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}