1use 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
38pub const DEFAULT_TIMEOUT_MS: u64 = 5_000;
40
41#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum ResolverKind {
48 System,
51 Named { server_id: String },
53 AdHoc,
55 ValidationEndpoint { name: String },
57}
58
59#[derive(Debug, Clone)]
61pub struct ResolverTarget {
62 pub kind: ResolverKind,
63 pub transport: ValidationTransport,
64 pub host: Option<String>,
68 pub port: Option<u16>,
70 pub url: Option<String>,
72 pub server_name: Option<String>,
74 pub tcp_only: bool,
76 pub timeout: Duration,
77}
78
79impl ResolverTarget {
80 #[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 #[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 #[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
196pub 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
214pub 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
355pub 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
382fn 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}