Skip to main content

dnslib/core/dns/
resolver.rs

1//! Transport-aware DNS resolver construction.
2//!
3//! Both the validation pipeline and the `dns query` subcommand build
4//! Hickory resolvers from configured DNS endpoints. The resolver setup
5//! is identical for both surfaces; only the source of the configuration
6//! differs (legacy `ValidationEndpointConfig` for validation, the
7//! per-server `[servers.dns|dot|doh|doq]` blocks for query). This module
8//! provides:
9//!
10//! - [`ResolverTarget`]: a small neutral struct holding everything a
11//!   resolver build needs (transport, host, port, URL, SNI, timeout).
12//! - Converters from legacy and new config shapes into a `ResolverTarget`.
13//! - [`resolver_config`] / [`build_resolver`]: produce
14//!   `ResolverConfig` / `Resolver<TokioRuntimeProvider>` from a target.
15//! - [`classify_hickory_error`]: map Hickory error strings to stable
16//!   [`ValidationFailureKind`] variants for downstream reporting.
17//!
18//! DoQ support is gated behind the `doq` Cargo feature. On default
19//! builds, a target with `ValidationTransport::Doq` returns
20//! [`ValidationFailureKind::UnsupportedTransport`].
21
22use std::{net::IpAddr, sync::Arc, time::Duration};
23
24use hickory_resolver::{
25    Resolver,
26    config::{ConnectionConfig, NameServerConfig, ResolverConfig, ResolverOpts},
27    net::runtime::TokioRuntimeProvider,
28};
29
30use crate::{
31    control_plane::config::{
32        DnsServerConfig, DnsTransportConfig, DohTransportConfig, DoqTransportConfig,
33        DotTransportConfig, ValidationEndpointConfig, ValidationTransport,
34    },
35    core::dns::validation::{DnsEndpointResolverResult, ValidationFailureKind},
36};
37
38/// Default per-attempt timeout when no override is supplied.
39pub const DEFAULT_TIMEOUT_MS: u64 = 5_000;
40
41/// Where a `ResolverTarget` was sourced from.
42///
43/// Used by the query subcommand to render the resolver header line and
44/// to populate the `resolver.kind` field in JSON output. Not consulted
45/// by the resolver build itself — purely descriptive.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum ResolverKind {
48    /// Built from the host OS resolver (no config). Not produced by
49    /// this module; the system path skips `ResolverTarget` entirely.
50    System,
51    /// Built from a configured `[[servers]]` entry's transport block.
52    Named { server_id: String },
53    /// Built from a CLI ad-hoc target (`--at` / `@addr`).
54    AdHoc,
55    /// Built from a legacy `[[servers.validation_endpoints]]` entry.
56    ValidationEndpoint { name: String },
57}
58
59/// Minimal data a resolver build needs, transport-tagged.
60#[derive(Debug, Clone)]
61pub struct ResolverTarget {
62    pub kind: ResolverKind,
63    pub transport: ValidationTransport,
64    /// Host portion: IP literal for DNS/DoT/DoQ; optional IP override
65    /// for DoH (when present, used in place of the URL host for
66    /// connection).
67    pub host: Option<String>,
68    /// Port. `None` means transport default (53/853/443/853).
69    pub port: Option<u16>,
70    /// DoH URL. Required for DoH; ignored otherwise.
71    pub url: Option<String>,
72    /// SNI / certificate name override for DoT/DoH/DoQ.
73    pub server_name: Option<String>,
74    /// Plain DNS should use TCP only. Ignored for encrypted transports.
75    pub tcp_only: bool,
76    pub timeout: Duration,
77}
78
79impl ResolverTarget {
80    /// Build a target from a legacy `[[servers.validation_endpoints]]`
81    /// entry. Preserves today's validation behaviour exactly.
82    #[must_use]
83    pub fn from_endpoint(endpoint: &ValidationEndpointConfig) -> Self {
84        Self {
85            kind: ResolverKind::ValidationEndpoint {
86                name: endpoint.name.clone(),
87            },
88            transport: endpoint.transport,
89            host: (!endpoint.address.trim().is_empty()).then(|| endpoint.address.clone()),
90            port: endpoint.port,
91            url: endpoint.url.clone(),
92            server_name: endpoint.tls_server_name.clone(),
93            tcp_only: false,
94            timeout: Duration::from_millis(endpoint.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)),
95        }
96    }
97
98    /// Build a target from a server's transport block. Returns `None`
99    /// when the requested block is absent on this server. (The caller —
100    /// the query subcommand — decides whether that's a `skipped` row
101    /// or a silent skip for `--all`.)
102    #[must_use]
103    pub fn from_server_block(
104        server: &DnsServerConfig,
105        transport: ValidationTransport,
106    ) -> Option<Self> {
107        let kind = ResolverKind::Named {
108            server_id: server.id.clone(),
109        };
110        match transport {
111            ValidationTransport::Dns => server.dns.as_ref().map(|block| {
112                let (host, port) = split_host_port(block.addr.as_deref());
113                Self {
114                    kind,
115                    transport,
116                    host,
117                    port,
118                    url: None,
119                    server_name: None,
120                    tcp_only: false,
121                    timeout: Duration::from_millis(block.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)),
122                }
123            }),
124            ValidationTransport::Dot => server.dot.as_ref().map(|block| {
125                let (host, port) = split_host_port(block.addr.as_deref());
126                Self {
127                    kind,
128                    transport,
129                    host,
130                    port,
131                    url: None,
132                    server_name: block.server_name.clone(),
133                    tcp_only: false,
134                    timeout: Duration::from_millis(block.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)),
135                }
136            }),
137            ValidationTransport::Doh => server.doh.as_ref().map(|block| {
138                let (host, port) = split_host_port(block.addr.as_deref());
139                Self {
140                    kind,
141                    transport,
142                    host,
143                    port,
144                    url: block.url.clone(),
145                    server_name: block.server_name.clone(),
146                    tcp_only: false,
147                    timeout: Duration::from_millis(block.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)),
148                }
149            }),
150            ValidationTransport::Doq => server.doq.as_ref().map(|block| {
151                let (host, port) = split_host_port(block.addr.as_deref());
152                Self {
153                    kind,
154                    transport,
155                    host,
156                    port,
157                    url: None,
158                    server_name: block.server_name.clone(),
159                    tcp_only: false,
160                    timeout: Duration::from_millis(block.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)),
161                }
162            }),
163        }
164    }
165
166    /// Returns true when the server's block for `transport` exists and
167    /// is `enabled = true`. Used by the query subcommand's transport
168    /// precedence and `--all` enumeration.
169    #[must_use]
170    pub fn is_enabled_on(server: &DnsServerConfig, transport: ValidationTransport) -> bool {
171        match transport {
172            ValidationTransport::Dns => server
173                .dns
174                .as_ref()
175                .map(|b: &DnsTransportConfig| b.enabled)
176                .unwrap_or(false),
177            ValidationTransport::Dot => server
178                .dot
179                .as_ref()
180                .map(|b: &DotTransportConfig| b.enabled)
181                .unwrap_or(false),
182            ValidationTransport::Doh => server
183                .doh
184                .as_ref()
185                .map(|b: &DohTransportConfig| b.enabled)
186                .unwrap_or(false),
187            ValidationTransport::Doq => server
188                .doq
189                .as_ref()
190                .map(|b: &DoqTransportConfig| b.enabled)
191                .unwrap_or(false),
192        }
193    }
194}
195
196/// Build a `ResolverConfig` for a target.
197///
198/// Returns `UnsupportedTransport` when DoQ is requested on a build
199/// without the `doq` feature enabled.
200pub fn resolver_config(target: &ResolverTarget) -> DnsEndpointResolverResult<ResolverConfig> {
201    let name_server = match target.transport {
202        ValidationTransport::Dns => plain_dns_name_server(target)?,
203        ValidationTransport::Dot => dot_name_server(target)?,
204        ValidationTransport::Doh => doh_name_server(target)?,
205        ValidationTransport::Doq => doq_name_server(target)?,
206    };
207    Ok(ResolverConfig::from_parts(
208        None,
209        Vec::new(),
210        vec![name_server],
211    ))
212}
213
214/// Build a Hickory `Resolver` for a target with the target's timeout.
215pub fn build_resolver(
216    target: &ResolverTarget,
217) -> DnsEndpointResolverResult<Resolver<TokioRuntimeProvider>> {
218    let mut opts = ResolverOpts::default();
219    opts.timeout = target.timeout;
220    opts.attempts = 1;
221
222    Resolver::builder_with_config(resolver_config(target)?, TokioRuntimeProvider::default())
223        .with_options(opts)
224        .build()
225        .map_err(|err| classify_hickory_error(target.transport, &err.to_string()))
226}
227
228fn plain_dns_name_server(target: &ResolverTarget) -> DnsEndpointResolverResult<NameServerConfig> {
229    let ip = target_ip(target)?;
230    let port = target.port.unwrap_or(53);
231    let mut udp = ConnectionConfig::udp();
232    udp.port = port;
233    let mut tcp = ConnectionConfig::tcp();
234    tcp.port = port;
235
236    let connections = if target.tcp_only {
237        vec![tcp]
238    } else {
239        vec![udp, tcp]
240    };
241    Ok(NameServerConfig::new(ip, true, connections))
242}
243
244fn dot_name_server(target: &ResolverTarget) -> DnsEndpointResolverResult<NameServerConfig> {
245    let ip = target_ip(target)?;
246    let server_name = tls_server_name(target)?.into();
247    let mut tls = ConnectionConfig::tls(server_name);
248    tls.port = target.port.unwrap_or(853);
249
250    Ok(NameServerConfig::new(ip, true, vec![tls]))
251}
252
253fn doh_name_server(target: &ResolverTarget) -> DnsEndpointResolverResult<NameServerConfig> {
254    let (host, path) = doh_url_parts(target)?;
255    let ip = match target.host.as_deref() {
256        Some(h) if !h.trim().is_empty() => h
257            .parse::<IpAddr>()
258            .map_err(|_| ValidationFailureKind::MalformedResponse)?,
259        _ => host
260            .parse::<IpAddr>()
261            .map_err(|_| ValidationFailureKind::MalformedResponse)?,
262    };
263    let server_name = target
264        .server_name
265        .as_deref()
266        .filter(|name| !name.trim().is_empty())
267        .unwrap_or(host)
268        .to_string();
269    let mut https = ConnectionConfig::https(Arc::from(server_name), Some(Arc::from(path)));
270    https.port = target.port.unwrap_or(443);
271
272    Ok(NameServerConfig::new(ip, true, vec![https]))
273}
274
275#[cfg(feature = "doq")]
276fn doq_name_server(target: &ResolverTarget) -> DnsEndpointResolverResult<NameServerConfig> {
277    let ip = target_ip(target)?;
278    let server_name = tls_server_name(target)?.into();
279    let mut quic = ConnectionConfig::quic(server_name);
280    quic.port = target.port.unwrap_or(853);
281
282    Ok(NameServerConfig::new(ip, true, vec![quic]))
283}
284
285#[cfg(not(feature = "doq"))]
286fn doq_name_server(_target: &ResolverTarget) -> DnsEndpointResolverResult<NameServerConfig> {
287    tracing::warn!(
288        "DoQ transport is not enabled in this build of dns. \
289         Rebuild with `--features doq` to enable DNS-over-QUIC."
290    );
291    Err(ValidationFailureKind::UnsupportedTransport)
292}
293
294fn target_ip(target: &ResolverTarget) -> DnsEndpointResolverResult<IpAddr> {
295    target
296        .host
297        .as_deref()
298        .ok_or(ValidationFailureKind::MalformedResponse)?
299        .parse::<IpAddr>()
300        .map_err(|_| ValidationFailureKind::MalformedResponse)
301}
302
303fn tls_server_name(target: &ResolverTarget) -> DnsEndpointResolverResult<String> {
304    target
305        .server_name
306        .as_deref()
307        .filter(|name| !name.trim().is_empty())
308        .map(str::to_string)
309        .or_else(|| {
310            target
311                .host
312                .as_deref()
313                .filter(|h| !h.trim().is_empty())
314                .map(str::to_string)
315        })
316        .ok_or(ValidationFailureKind::MalformedResponse)
317}
318
319fn doh_url_parts(target: &ResolverTarget) -> DnsEndpointResolverResult<(&str, &str)> {
320    let url = target
321        .url
322        .as_deref()
323        .ok_or(ValidationFailureKind::MalformedResponse)?;
324    let without_scheme = url
325        .strip_prefix("https://")
326        .ok_or(ValidationFailureKind::DohHttpFailure)?;
327    let (authority, path) = without_scheme
328        .split_once('/')
329        .unwrap_or((without_scheme, "dns-query"));
330    let authority = authority
331        .rsplit_once('@')
332        .map_or(authority, |(_, host_port)| host_port);
333    let host = if let Some(stripped) = authority.strip_prefix('[') {
334        stripped.split_once(']').map_or(authority, |(host, _)| host)
335    } else {
336        authority
337            .split_once(':')
338            .map_or(authority, |(host, _)| host)
339    };
340
341    if host.trim().is_empty() {
342        return Err(ValidationFailureKind::MalformedResponse);
343    }
344
345    Ok((
346        host,
347        if path.is_empty() {
348            "/dns-query"
349        } else {
350            &url[url.len() - path.len() - 1..]
351        },
352    ))
353}
354
355/// Map a Hickory error string into a stable [`ValidationFailureKind`].
356///
357/// Used by both the validation pipeline and (in due course) the query
358/// subcommand, so the categories stay aligned across surfaces.
359pub fn classify_hickory_error(
360    transport: ValidationTransport,
361    error: &str,
362) -> ValidationFailureKind {
363    let error = error.to_ascii_lowercase();
364
365    if error.contains("timed out") || error.contains("timeout") {
366        ValidationFailureKind::Timeout
367    } else if error.contains("nxdomain") || error.contains("no records found") {
368        ValidationFailureKind::Nxdomain
369    } else if error.contains("servfail") || error.contains("server failure") {
370        ValidationFailureKind::Servfail
371    } else if error.contains("refused") {
372        ValidationFailureKind::Refused
373    } else if matches!(transport, ValidationTransport::Dot) || error.contains("tls") {
374        ValidationFailureKind::TlsFailure
375    } else if matches!(transport, ValidationTransport::Doh) || error.contains("http") {
376        ValidationFailureKind::DohHttpFailure
377    } else {
378        ValidationFailureKind::MalformedResponse
379    }
380}
381
382/// Split an `addr` of the shape `"host[:port]"` (with IPv6 brackets
383/// optionally allowed) into `(host, port)`. Returns `(Some(addr),
384/// None)` when there's no port, `(None, None)` for `None`/empty input.
385fn split_host_port(addr: Option<&str>) -> (Option<String>, Option<u16>) {
386    let raw = match addr {
387        Some(s) if !s.trim().is_empty() => s.trim(),
388        _ => return (None, None),
389    };
390
391    if let Some(stripped) = raw.strip_prefix('[') {
392        if let Some((host, rest)) = stripped.split_once(']') {
393            let port = rest.strip_prefix(':').and_then(|p| p.parse::<u16>().ok());
394            return (Some(host.to_string()), port);
395        }
396        return (Some(raw.to_string()), None);
397    }
398
399    if let Some((host, port_s)) = raw.rsplit_once(':')
400        && let Ok(port) = port_s.parse::<u16>()
401        && !host.is_empty()
402        && !host.contains(':')
403    {
404        return (Some(host.to_string()), Some(port));
405    }
406
407    (Some(raw.to_string()), None)
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::control_plane::config::{
414        DnsTransportConfig, DohTransportConfig, DoqTransportConfig, DotTransportConfig,
415        McpPermissions, VendorKind,
416    };
417    use rstest::rstest;
418
419    fn server_with_blocks() -> DnsServerConfig {
420        DnsServerConfig {
421            id: "dns1".to_string(),
422            vendor: VendorKind::Technitium,
423            location: None,
424            base_url: None,
425            base_url_env: None,
426            token: None,
427            token_env: None,
428            org_id: None,
429            cluster: None,
430            dns: Some(DnsTransportConfig {
431                enabled: true,
432                addr: Some("10.5.0.53:53".to_string()),
433                timeout_ms: Some(1500),
434            }),
435            dot: Some(DotTransportConfig {
436                enabled: true,
437                addr: Some("10.5.0.53:853".to_string()),
438                server_name: Some("dns1.hankin.io".to_string()),
439                timeout_ms: None,
440            }),
441            doh: Some(DohTransportConfig {
442                enabled: false,
443                url: Some("https://dns1.hankin.io/dns-query".to_string()),
444                addr: None,
445                server_name: None,
446                timeout_ms: None,
447            }),
448            doq: Some(DoqTransportConfig {
449                enabled: true,
450                addr: Some("10.5.0.53:853".to_string()),
451                server_name: Some("dns1.hankin.io".to_string()),
452                timeout_ms: None,
453            }),
454            mcp: McpPermissions::default(),
455            validation_endpoints: Vec::new(),
456        }
457    }
458
459    #[rstest]
460    #[case::no_port("10.5.0.53", Some("10.5.0.53"), None)]
461    #[case::with_port("10.5.0.53:853", Some("10.5.0.53"), Some(853))]
462    #[case::host_no_port("dns.example", Some("dns.example"), None)]
463    #[case::host_port("dns.example:53", Some("dns.example"), Some(53))]
464    #[case::empty("", None, None)]
465    #[case::ipv6_no_port("[2001:db8::1]", Some("2001:db8::1"), None)]
466    #[case::ipv6_port("[2001:db8::1]:853", Some("2001:db8::1"), Some(853))]
467    fn split_host_port_cases(
468        #[case] input: &str,
469        #[case] expected_host: Option<&str>,
470        #[case] expected_port: Option<u16>,
471    ) {
472        let parsed = split_host_port(Some(input));
473        assert_eq!(parsed.0.as_deref(), expected_host);
474        assert_eq!(parsed.1, expected_port);
475    }
476
477    #[test]
478    fn split_host_port_none_for_none_input() {
479        assert_eq!(split_host_port(None), (None, None));
480    }
481
482    #[test]
483    fn from_server_block_dns_parses_addr() {
484        let server = server_with_blocks();
485        let target = ResolverTarget::from_server_block(&server, ValidationTransport::Dns).unwrap();
486        assert_eq!(target.transport, ValidationTransport::Dns);
487        assert_eq!(target.host.as_deref(), Some("10.5.0.53"));
488        assert_eq!(target.port, Some(53));
489        assert_eq!(target.timeout, Duration::from_millis(1500));
490        assert!(
491            matches!(target.kind, ResolverKind::Named { ref server_id } if server_id == "dns1")
492        );
493    }
494
495    #[test]
496    fn from_server_block_dot_picks_up_server_name() {
497        let server = server_with_blocks();
498        let target = ResolverTarget::from_server_block(&server, ValidationTransport::Dot).unwrap();
499        assert_eq!(target.transport, ValidationTransport::Dot);
500        assert_eq!(target.host.as_deref(), Some("10.5.0.53"));
501        assert_eq!(target.port, Some(853));
502        assert_eq!(target.server_name.as_deref(), Some("dns1.hankin.io"));
503    }
504
505    #[test]
506    fn from_server_block_doh_carries_url() {
507        let server = server_with_blocks();
508        let target = ResolverTarget::from_server_block(&server, ValidationTransport::Doh).unwrap();
509        assert_eq!(
510            target.url.as_deref(),
511            Some("https://dns1.hankin.io/dns-query"),
512        );
513    }
514
515    #[test]
516    fn from_server_block_returns_none_when_block_absent() {
517        let mut server = server_with_blocks();
518        server.dns = None;
519        assert!(ResolverTarget::from_server_block(&server, ValidationTransport::Dns).is_none());
520    }
521
522    #[test]
523    fn is_enabled_on_reflects_block_state() {
524        let server = server_with_blocks();
525        assert!(ResolverTarget::is_enabled_on(
526            &server,
527            ValidationTransport::Dns
528        ));
529        assert!(ResolverTarget::is_enabled_on(
530            &server,
531            ValidationTransport::Dot
532        ));
533        assert!(!ResolverTarget::is_enabled_on(
534            &server,
535            ValidationTransport::Doh
536        ));
537        assert!(ResolverTarget::is_enabled_on(
538            &server,
539            ValidationTransport::Doq
540        ));
541
542        let mut without_doq = server_with_blocks();
543        without_doq.doq = None;
544        assert!(!ResolverTarget::is_enabled_on(
545            &without_doq,
546            ValidationTransport::Doq
547        ));
548    }
549
550    #[cfg(not(feature = "doq"))]
551    #[test]
552    fn doq_resolver_unsupported_without_feature() {
553        let server = server_with_blocks();
554        let target = ResolverTarget::from_server_block(&server, ValidationTransport::Doq).unwrap();
555        let err = resolver_config(&target).expect_err("doq should fail without feature");
556        assert!(matches!(err, ValidationFailureKind::UnsupportedTransport));
557    }
558
559    #[cfg(feature = "doq")]
560    #[test]
561    fn doq_resolver_builds_with_feature() {
562        let server = server_with_blocks();
563        let target = ResolverTarget::from_server_block(&server, ValidationTransport::Doq).unwrap();
564        resolver_config(&target).expect("doq resolver should build with feature enabled");
565    }
566
567    #[test]
568    fn from_endpoint_preserves_validation_shape() {
569        let endpoint = ValidationEndpointConfig {
570            name: "cloudflare-doh".to_string(),
571            transport: ValidationTransport::Doh,
572            address: String::new(),
573            port: None,
574            url: Some("https://cloudflare-dns.com/dns-query".to_string()),
575            tls_server_name: None,
576            enabled: true,
577            timeout_ms: Some(2000),
578        };
579
580        let target = ResolverTarget::from_endpoint(&endpoint);
581
582        assert_eq!(target.transport, ValidationTransport::Doh);
583        assert_eq!(target.host, None);
584        assert_eq!(
585            target.url.as_deref(),
586            Some("https://cloudflare-dns.com/dns-query"),
587        );
588        assert_eq!(target.timeout, Duration::from_millis(2000));
589        assert!(matches!(
590            target.kind,
591            ResolverKind::ValidationEndpoint { ref name } if name == "cloudflare-doh"
592        ));
593    }
594}