1use std::{collections::HashMap, net::IpAddr, str::FromStr};
40
41use async_trait::async_trait;
42use hickory_resolver::{
43 ResolveErrorKind, ResolverBuilder, TokioResolver, name_server::TokioConnectionProvider,
44 proto::rr::rdata::TXT,
45};
46use sciparse::{address::ip_addr::ScionIpAddr, identifier::isd_asn::IsdAsn};
47use thiserror::Error;
48
49use super::{InvalidEntry, ResolveError, ScionDnsResolver};
50
51const SCION_TXT_PREFIX: &str = "scion=v1;";
52
53#[derive(Clone, Debug)]
75pub struct ScionTxtDnsResolver {
76 resolver: TokioResolver,
77 overrides: HashMap<String, Vec<ScionIpAddr>>,
78}
79
80impl ScionTxtDnsResolver {
81 pub fn new() -> Result<Self, TxtResolverError> {
90 let builder = Self::builder()?;
91 Self::from_builder(builder)
92 }
93
94 #[must_use]
96 pub fn with_override(self, domain: &str, addrs: Vec<ScionIpAddr>) -> Self {
97 self.with_overrides(vec![(domain, addrs)])
98 }
99
100 #[must_use]
102 pub fn with_overrides<D, I>(mut self, overrides: I) -> Self
103 where
104 D: Into<String>,
105 I: IntoIterator<Item = (D, Vec<ScionIpAddr>)>,
106 {
107 for (domain, addrs) in overrides {
108 self.overrides.insert(domain.into(), addrs);
109 }
110 self
111 }
112
113 pub fn from_builder(
125 builder: ResolverBuilder<TokioConnectionProvider>,
126 ) -> Result<Self, TxtResolverError> {
127 Ok(Self {
128 resolver: builder.build(),
129 overrides: HashMap::new(),
130 })
131 }
132
133 pub fn builder() -> Result<ResolverBuilder<TokioConnectionProvider>, TxtResolverError> {
147 #[cfg(any(target_os = "android", target_os = "ios"))]
148 {
149 use hickory_resolver::config::ResolverConfig;
150 Ok(TokioResolver::builder_with_config(
153 ResolverConfig::google(),
154 TokioConnectionProvider::default(),
155 ))
156 }
157 #[cfg(not(any(target_os = "android", target_os = "ios")))]
158 {
159 Ok(TokioResolver::builder_tokio()?)
160 }
161 }
162}
163
164#[async_trait]
165impl ScionDnsResolver for ScionTxtDnsResolver {
166 async fn resolve(&self, domain: &str) -> Result<Vec<ScionIpAddr>, ResolveError> {
167 if let Some(addrs) = self.overrides.get(domain) {
168 return Ok(addrs.clone());
169 }
170
171 let lookup = self
172 .resolver
173 .txt_lookup(domain)
174 .await
175 .map_err(|err| classify_lookup_error(domain, &err))?;
176
177 let mut txt_records = Vec::new();
178 let mut invalid_entries = Vec::new();
179 for txt in lookup.iter() {
180 match txt_record_to_string(txt) {
181 Ok(txt_record) => txt_records.push(txt_record),
182 Err(err) => invalid_entries.push(err),
183 }
184 }
185
186 resolve_txt_records_with_invalid(domain, txt_records, invalid_entries)
187 }
188}
189
190#[derive(Debug, Error)]
196#[non_exhaustive]
197pub enum TxtResolverError {
198 #[error("dns resolver configuration failed: {message}")]
200 DnsConfig {
201 message: String,
203 #[source]
205 source: Box<dyn std::error::Error + Send + Sync>,
206 },
207}
208
209impl From<hickory_resolver::ResolveError> for TxtResolverError {
210 fn from(error: hickory_resolver::ResolveError) -> Self {
211 Self::DnsConfig {
212 message: error.to_string(),
213 source: Box::new(error),
214 }
215 }
216}
217
218impl PartialEq for TxtResolverError {
219 fn eq(&self, other: &Self) -> bool {
220 match (self, other) {
221 (Self::DnsConfig { message: a, .. }, Self::DnsConfig { message: b, .. }) => a == b,
222 }
223 }
224}
225
226fn classify_lookup_error(domain: &str, err: &hickory_resolver::ResolveError) -> ResolveError {
236 match err.kind() {
237 ResolveErrorKind::Proto(proto) if proto.is_no_records_found() => {
238 ResolveError::NoValidEntries {
239 domain: domain.to_string(),
240 invalid_entries: Vec::new(),
241 }
242 }
243 _ => ResolveError::DnsLookup(err.to_string()),
244 }
245}
246
247#[derive(Debug, Error)]
248enum TxtParseError {
249 #[error("missing TXT address list")]
250 MissingAddressList,
251 #[error("expected '[' at: {0}")]
252 ExpectedOpenBracket(String),
253 #[error("missing closing ']' in: {0}")]
254 MissingCloseBracket(String),
255 #[error("expected comma separator in: {0}")]
256 MissingSeparator(String),
257 #[error("invalid ISD-AS: {0}")]
258 InvalidIsdAsn(#[from] sciparse::address::AddressParseError),
259 #[error("invalid host address: {0}")]
260 InvalidHost(#[from] std::net::AddrParseError),
261 #[error("expected ',' after entry in: {0}")]
262 ExpectedComma(String),
263}
264
265#[cfg(test)]
266fn resolve_txt_records(
267 domain: &str,
268 records: impl IntoIterator<Item = String>,
269) -> Result<Vec<ScionIpAddr>, ResolveError> {
270 resolve_txt_records_with_invalid(domain, records, Vec::new())
271}
272
273fn resolve_txt_records_with_invalid(
274 domain: &str,
275 records: impl IntoIterator<Item = String>,
276 mut invalid: Vec<InvalidEntry>,
277) -> Result<Vec<ScionIpAddr>, ResolveError> {
278 let mut valid = Vec::new();
279
280 for record in records {
281 let Some(payload) = record.strip_prefix(SCION_TXT_PREFIX) else {
282 continue;
283 };
284
285 match parse_txt_payload(payload) {
286 Ok(mut addresses) => valid.append(&mut addresses),
287 Err(err) => invalid.push(InvalidEntry::new(record, err.to_string())),
288 }
289 }
290
291 if valid.is_empty() {
292 return Err(ResolveError::NoValidEntries {
293 domain: domain.to_string(),
294 invalid_entries: invalid,
295 });
296 }
297
298 if !invalid.is_empty() {
299 let details = format_invalid_entries(&invalid);
300 tracing::info!(
301 domain,
302 invalid_entries = invalid.len(),
303 details = ?details,
304 "Ignoring invalid SCION TXT entries"
305 );
306 }
307
308 Ok(valid)
309}
310
311fn parse_txt_payload(payload: &str) -> Result<Vec<ScionIpAddr>, TxtParseError> {
312 let mut remaining = payload.trim();
313 if remaining.is_empty() {
314 return Err(TxtParseError::MissingAddressList);
315 }
316
317 let mut addresses = Vec::new();
318 while !remaining.is_empty() {
319 if !remaining.starts_with('[') {
320 return Err(TxtParseError::ExpectedOpenBracket(remaining.to_string()));
321 }
322
323 let close_idx = remaining
324 .find(']')
325 .ok_or_else(|| TxtParseError::MissingCloseBracket(remaining.to_string()))?;
326 let entry = remaining[1..close_idx].trim();
327 let rest = remaining[close_idx + 1..].trim();
328
329 let (isd_asn_str, host_str) = entry
330 .split_once(',')
331 .ok_or_else(|| TxtParseError::MissingSeparator(entry.to_string()))?;
332
333 let isd_asn = IsdAsn::from_str(isd_asn_str.trim())?;
334 let host = IpAddr::from_str(host_str.trim())?;
335
336 addresses.push(ScionIpAddr::new(isd_asn, host));
337
338 if rest.is_empty() {
339 break;
340 }
341
342 if !rest.starts_with(',') {
343 return Err(TxtParseError::ExpectedComma(rest.to_string()));
344 }
345
346 remaining = rest[1..].trim();
347 }
348
349 Ok(addresses)
350}
351
352fn txt_record_to_string(txt: &TXT) -> Result<String, InvalidEntry> {
353 let bytes: Vec<u8> = txt
354 .txt_data()
355 .iter()
356 .flat_map(|chunk| chunk.iter())
357 .copied()
358 .collect();
359
360 String::from_utf8(bytes)
361 .map_err(|_| InvalidEntry::new("<invalid-utf8>", "TXT entry is not valid UTF-8"))
362}
363
364fn format_invalid_entries(entries: &[InvalidEntry]) -> Vec<String> {
365 entries
366 .iter()
367 .map(|entry| format!("{} ({})", entry.raw(), entry.reason()))
368 .collect()
369}
370
371#[cfg(test)]
372mod tests {
373 use hickory_resolver::proto::{
374 ProtoError, ProtoErrorKind,
375 op::{Query, ResponseCode},
376 rr::{Name, RecordType},
377 };
378
379 use super::*;
380
381 fn no_records_error(
384 domain: &str,
385 response_code: ResponseCode,
386 ) -> hickory_resolver::ResolveError {
387 let query = Query::query(Name::from_str(domain).expect("valid name"), RecordType::TXT);
388 ResolveErrorKind::Proto(ProtoError::nx_error(
389 Box::new(query),
390 None,
391 None,
392 None,
393 response_code,
394 false,
395 None,
396 ))
397 .into()
398 }
399
400 #[test]
401 fn classify_empty_answer_as_no_valid_entries() {
402 let raw = no_records_error("example.com.", ResponseCode::NoError);
403
404 let err = classify_lookup_error("example.com", &raw);
405
406 assert_eq!(
407 err,
408 ResolveError::NoValidEntries {
409 domain: "example.com".to_string(),
410 invalid_entries: Vec::new(),
411 }
412 );
413 assert!(!err.is_transient());
414 }
415
416 #[test]
417 fn classify_nonexistent_name_as_no_valid_entries() {
418 let raw = no_records_error("example.com.", ResponseCode::NXDomain);
419
420 let err = classify_lookup_error("example.com", &raw);
421
422 assert_eq!(
423 err,
424 ResolveError::NoValidEntries {
425 domain: "example.com".to_string(),
426 invalid_entries: Vec::new(),
427 }
428 );
429 assert!(!err.is_transient());
430 }
431
432 #[test]
433 fn classify_timeout_as_transient_lookup_failure() {
434 let raw: hickory_resolver::ResolveError =
435 ResolveErrorKind::Proto(ProtoError::from(ProtoErrorKind::Timeout)).into();
436
437 let err = classify_lookup_error("example.com", &raw);
438
439 assert!(matches!(err, ResolveError::DnsLookup(_)), "{err:?}");
440 assert!(err.is_transient());
441 }
442
443 #[test]
444 fn parse_txt_payload_single() {
445 let addrs = parse_txt_payload("[19-ff00:0:110,192.0.2.1]").expect("valid payload");
446 assert_eq!(addrs.len(), 1);
447 assert_eq!(
448 addrs[0],
449 ScionIpAddr::from_str("19-ff00:0:110,192.0.2.1").unwrap()
450 );
451 }
452
453 #[test]
454 fn parse_txt_payload_multiple() {
455 let addrs = parse_txt_payload("[19-ff00:0:110,192.0.2.1],[19-ff00:0:111,2001:db8::1]")
456 .expect("valid payload");
457 assert_eq!(addrs.len(), 2);
458 }
459
460 #[test]
461 fn resolve_txt_records_mixed_validity() {
462 let records = vec![
463 "scion=v1;[19-ff00:0:110,192.0.2.1]".to_string(),
464 "scion=v1;[bad,192.0.2.2]".to_string(),
465 ];
466
467 let resolved = resolve_txt_records("example.com", records).expect("valid addresses");
468 assert_eq!(resolved.len(), 1);
469 }
470
471 #[test]
472 fn resolve_txt_records_no_valid_entries() {
473 let records = vec!["scion=v1;[bad,192.0.2.2]".to_string()];
474
475 let err = resolve_txt_records("example.com", records).expect_err("no valid entries");
476 match err {
477 ResolveError::NoValidEntries { domain, .. } => {
478 assert_eq!(domain, "example.com");
479 }
480 other => panic!("unexpected error: {other:?}"),
481 }
482 }
483
484 #[test]
485 fn parse_txt_payload_allows_whitespace_between_entries() {
486 let addrs = parse_txt_payload("[19-ff00:0:110,192.0.2.1] , [19-ff00:0:111,2001:db8::1]")
487 .expect("valid payload");
488 assert_eq!(addrs.len(), 2);
489 }
490
491 #[tokio::test]
492 async fn with_override_returns_single_address() {
493 let addr = ScionIpAddr::from_str("19-ff00:0:110,192.0.2.1").unwrap();
494 let resolver = ScionTxtDnsResolver::new()
495 .unwrap()
496 .with_override("example.com", vec![addr]);
497
498 let result = ScionDnsResolver::resolve(&resolver, "example.com")
499 .await
500 .unwrap();
501 assert_eq!(result, vec![addr]);
502 }
503
504 #[tokio::test]
505 async fn with_overrides_returns_all_addresses() {
506 let addr1 = ScionIpAddr::from_str("19-ff00:0:110,192.0.2.1").unwrap();
507 let addr2 = ScionIpAddr::from_str("19-ff00:0:111,2001:db8::1").unwrap();
508 let resolver = ScionTxtDnsResolver::new()
509 .unwrap()
510 .with_override("example.com", vec![addr1, addr2]);
511
512 let result = ScionDnsResolver::resolve(&resolver, "example.com")
513 .await
514 .unwrap();
515 assert_eq!(result, vec![addr1, addr2]);
516 }
517
518 #[tokio::test]
519 async fn with_multi_overrides_handles_multiple_domains() {
520 let addr1 = ScionIpAddr::from_str("19-ff00:0:110,192.0.2.1").unwrap();
521 let addr2 = ScionIpAddr::from_str("19-ff00:0:111,192.0.2.2").unwrap();
522 let resolver = ScionTxtDnsResolver::new().unwrap().with_overrides([
523 ("first.example.com", vec![addr1]),
524 ("second.example.com", vec![addr2]),
525 ]);
526
527 let result1 = ScionDnsResolver::resolve(&resolver, "first.example.com")
528 .await
529 .unwrap();
530 assert_eq!(result1, vec![addr1]);
531
532 let result2 = ScionDnsResolver::resolve(&resolver, "second.example.com")
533 .await
534 .unwrap();
535 assert_eq!(result2, vec![addr2]);
536 }
537
538 #[tokio::test]
539 async fn with_override_later_call_replaces_previous() {
540 let addr1 = ScionIpAddr::from_str("19-ff00:0:110,192.0.2.1").unwrap();
541 let addr2 = ScionIpAddr::from_str("19-ff00:0:111,192.0.2.2").unwrap();
542 let resolver = ScionTxtDnsResolver::new()
543 .unwrap()
544 .with_override("example.com", vec![addr1])
545 .with_override("example.com", vec![addr2]);
546
547 let result = ScionDnsResolver::resolve(&resolver, "example.com")
548 .await
549 .unwrap();
550 assert_eq!(result, vec![addr2]);
551 }
552}