1use std::{collections::HashMap, net::IpAddr, str::FromStr};
40
41use async_trait::async_trait;
42use hickory_resolver::{
43 ResolverBuilder, TokioResolver, name_server::TokioConnectionProvider, proto::rr::rdata::TXT,
44};
45use sciparse::{address::ip_addr::ScionIpAddr, identifier::isd_asn::IsdAsn};
46use thiserror::Error;
47
48use super::{InvalidEntry, ResolveError, ScionDnsResolver};
49
50const SCION_TXT_PREFIX: &str = "scion=v1;";
51
52#[derive(Clone, Debug)]
60pub struct ScionTxtDnsResolver {
61 resolver: TokioResolver,
62 overrides: HashMap<String, Vec<ScionIpAddr>>,
63}
64
65impl ScionTxtDnsResolver {
66 pub fn new() -> Result<Self, TxtResolverError> {
75 let builder = Self::builder()?;
76 Self::from_builder(builder)
77 }
78
79 #[must_use]
81 pub fn with_override(self, domain: &str, addrs: Vec<ScionIpAddr>) -> Self {
82 self.with_overrides(vec![(domain, addrs)])
83 }
84
85 #[must_use]
87 pub fn with_overrides<D, I>(mut self, overrides: I) -> Self
88 where
89 D: Into<String>,
90 I: IntoIterator<Item = (D, Vec<ScionIpAddr>)>,
91 {
92 for (domain, addrs) in overrides {
93 self.overrides.insert(domain.into(), addrs);
94 }
95 self
96 }
97
98 pub fn from_builder(
110 builder: ResolverBuilder<TokioConnectionProvider>,
111 ) -> Result<Self, TxtResolverError> {
112 Ok(Self {
113 resolver: builder.build(),
114 overrides: HashMap::new(),
115 })
116 }
117
118 pub fn builder() -> Result<ResolverBuilder<TokioConnectionProvider>, TxtResolverError> {
132 #[cfg(any(target_os = "android", target_os = "ios"))]
133 {
134 use hickory_resolver::config::ResolverConfig;
135 Ok(TokioResolver::builder_with_config(
138 ResolverConfig::google(),
139 TokioConnectionProvider::default(),
140 ))
141 }
142 #[cfg(not(any(target_os = "android", target_os = "ios")))]
143 {
144 Ok(TokioResolver::builder_tokio()?)
145 }
146 }
147}
148
149#[async_trait]
150impl ScionDnsResolver for ScionTxtDnsResolver {
151 async fn resolve(&self, domain: &str) -> Result<Vec<ScionIpAddr>, ResolveError> {
152 if let Some(addrs) = self.overrides.get(domain) {
153 return Ok(addrs.clone());
154 }
155
156 let lookup = self
157 .resolver
158 .txt_lookup(domain)
159 .await
160 .map_err(|err| ResolveError::DnsLookup(err.to_string()))?;
161
162 let mut txt_records = Vec::new();
163 let mut invalid_entries = Vec::new();
164 for txt in lookup.iter() {
165 match txt_record_to_string(txt) {
166 Ok(txt_record) => txt_records.push(txt_record),
167 Err(err) => invalid_entries.push(err),
168 }
169 }
170
171 resolve_txt_records_with_invalid(domain, txt_records, invalid_entries)
172 }
173}
174
175#[derive(Debug, Error)]
181#[non_exhaustive]
182pub enum TxtResolverError {
183 #[error("dns resolver configuration failed: {message}")]
185 DnsConfig {
186 message: String,
188 #[source]
190 source: Box<dyn std::error::Error + Send + Sync>,
191 },
192}
193
194impl From<hickory_resolver::ResolveError> for TxtResolverError {
195 fn from(error: hickory_resolver::ResolveError) -> Self {
196 Self::DnsConfig {
197 message: error.to_string(),
198 source: Box::new(error),
199 }
200 }
201}
202
203impl PartialEq for TxtResolverError {
204 fn eq(&self, other: &Self) -> bool {
205 match (self, other) {
206 (Self::DnsConfig { message: a, .. }, Self::DnsConfig { message: b, .. }) => a == b,
207 }
208 }
209}
210
211#[derive(Debug, Error)]
212enum TxtParseError {
213 #[error("missing TXT address list")]
214 MissingAddressList,
215 #[error("expected '[' at: {0}")]
216 ExpectedOpenBracket(String),
217 #[error("missing closing ']' in: {0}")]
218 MissingCloseBracket(String),
219 #[error("expected comma separator in: {0}")]
220 MissingSeparator(String),
221 #[error("invalid ISD-AS: {0}")]
222 InvalidIsdAsn(#[from] sciparse::address::AddressParseError),
223 #[error("invalid host address: {0}")]
224 InvalidHost(#[from] std::net::AddrParseError),
225 #[error("expected ',' after entry in: {0}")]
226 ExpectedComma(String),
227}
228
229#[cfg(test)]
230fn resolve_txt_records(
231 domain: &str,
232 records: impl IntoIterator<Item = String>,
233) -> Result<Vec<ScionIpAddr>, ResolveError> {
234 resolve_txt_records_with_invalid(domain, records, Vec::new())
235}
236
237fn resolve_txt_records_with_invalid(
238 domain: &str,
239 records: impl IntoIterator<Item = String>,
240 mut invalid: Vec<InvalidEntry>,
241) -> Result<Vec<ScionIpAddr>, ResolveError> {
242 let mut valid = Vec::new();
243
244 for record in records {
245 let Some(payload) = record.strip_prefix(SCION_TXT_PREFIX) else {
246 continue;
247 };
248
249 match parse_txt_payload(payload) {
250 Ok(mut addresses) => valid.append(&mut addresses),
251 Err(err) => invalid.push(InvalidEntry::new(record, err.to_string())),
252 }
253 }
254
255 if valid.is_empty() {
256 return Err(ResolveError::NoValidEntries {
257 domain: domain.to_string(),
258 invalid_entries: invalid,
259 });
260 }
261
262 if !invalid.is_empty() {
263 let details = format_invalid_entries(&invalid);
264 tracing::info!(
265 domain,
266 invalid_entries = invalid.len(),
267 details = ?details,
268 "Ignoring invalid SCION TXT entries"
269 );
270 }
271
272 Ok(valid)
273}
274
275fn parse_txt_payload(payload: &str) -> Result<Vec<ScionIpAddr>, TxtParseError> {
276 let mut remaining = payload.trim();
277 if remaining.is_empty() {
278 return Err(TxtParseError::MissingAddressList);
279 }
280
281 let mut addresses = Vec::new();
282 while !remaining.is_empty() {
283 if !remaining.starts_with('[') {
284 return Err(TxtParseError::ExpectedOpenBracket(remaining.to_string()));
285 }
286
287 let close_idx = remaining
288 .find(']')
289 .ok_or_else(|| TxtParseError::MissingCloseBracket(remaining.to_string()))?;
290 let entry = remaining[1..close_idx].trim();
291 let rest = remaining[close_idx + 1..].trim();
292
293 let (isd_asn_str, host_str) = entry
294 .split_once(',')
295 .ok_or_else(|| TxtParseError::MissingSeparator(entry.to_string()))?;
296
297 let isd_asn = IsdAsn::from_str(isd_asn_str.trim())?;
298 let host = IpAddr::from_str(host_str.trim())?;
299
300 addresses.push(ScionIpAddr::new(isd_asn, host));
301
302 if rest.is_empty() {
303 break;
304 }
305
306 if !rest.starts_with(',') {
307 return Err(TxtParseError::ExpectedComma(rest.to_string()));
308 }
309
310 remaining = rest[1..].trim();
311 }
312
313 Ok(addresses)
314}
315
316fn txt_record_to_string(txt: &TXT) -> Result<String, InvalidEntry> {
317 let bytes: Vec<u8> = txt
318 .txt_data()
319 .iter()
320 .flat_map(|chunk| chunk.iter())
321 .copied()
322 .collect();
323
324 String::from_utf8(bytes)
325 .map_err(|_| InvalidEntry::new("<invalid-utf8>", "TXT entry is not valid UTF-8"))
326}
327
328fn format_invalid_entries(entries: &[InvalidEntry]) -> Vec<String> {
329 entries
330 .iter()
331 .map(|entry| format!("{} ({})", entry.raw(), entry.reason()))
332 .collect()
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn parse_txt_payload_single() {
341 let addrs = parse_txt_payload("[19-ff00:0:110,192.0.2.1]").expect("valid payload");
342 assert_eq!(addrs.len(), 1);
343 assert_eq!(
344 addrs[0],
345 ScionIpAddr::from_str("19-ff00:0:110,192.0.2.1").unwrap()
346 );
347 }
348
349 #[test]
350 fn parse_txt_payload_multiple() {
351 let addrs = parse_txt_payload("[19-ff00:0:110,192.0.2.1],[19-ff00:0:111,2001:db8::1]")
352 .expect("valid payload");
353 assert_eq!(addrs.len(), 2);
354 }
355
356 #[test]
357 fn resolve_txt_records_mixed_validity() {
358 let records = vec![
359 "scion=v1;[19-ff00:0:110,192.0.2.1]".to_string(),
360 "scion=v1;[bad,192.0.2.2]".to_string(),
361 ];
362
363 let resolved = resolve_txt_records("example.com", records).expect("valid addresses");
364 assert_eq!(resolved.len(), 1);
365 }
366
367 #[test]
368 fn resolve_txt_records_no_valid_entries() {
369 let records = vec!["scion=v1;[bad,192.0.2.2]".to_string()];
370
371 let err = resolve_txt_records("example.com", records).expect_err("no valid entries");
372 match err {
373 ResolveError::NoValidEntries { domain, .. } => {
374 assert_eq!(domain, "example.com");
375 }
376 other => panic!("unexpected error: {other:?}"),
377 }
378 }
379
380 #[test]
381 fn parse_txt_payload_allows_whitespace_between_entries() {
382 let addrs = parse_txt_payload("[19-ff00:0:110,192.0.2.1] , [19-ff00:0:111,2001:db8::1]")
383 .expect("valid payload");
384 assert_eq!(addrs.len(), 2);
385 }
386
387 #[tokio::test]
388 async fn with_override_returns_single_address() {
389 let addr = ScionIpAddr::from_str("19-ff00:0:110,192.0.2.1").unwrap();
390 let resolver = ScionTxtDnsResolver::new()
391 .unwrap()
392 .with_override("example.com", vec![addr]);
393
394 let result = ScionDnsResolver::resolve(&resolver, "example.com")
395 .await
396 .unwrap();
397 assert_eq!(result, vec![addr]);
398 }
399
400 #[tokio::test]
401 async fn with_overrides_returns_all_addresses() {
402 let addr1 = ScionIpAddr::from_str("19-ff00:0:110,192.0.2.1").unwrap();
403 let addr2 = ScionIpAddr::from_str("19-ff00:0:111,2001:db8::1").unwrap();
404 let resolver = ScionTxtDnsResolver::new()
405 .unwrap()
406 .with_override("example.com", vec![addr1, addr2]);
407
408 let result = ScionDnsResolver::resolve(&resolver, "example.com")
409 .await
410 .unwrap();
411 assert_eq!(result, vec![addr1, addr2]);
412 }
413
414 #[tokio::test]
415 async fn with_multi_overrides_handles_multiple_domains() {
416 let addr1 = ScionIpAddr::from_str("19-ff00:0:110,192.0.2.1").unwrap();
417 let addr2 = ScionIpAddr::from_str("19-ff00:0:111,192.0.2.2").unwrap();
418 let resolver = ScionTxtDnsResolver::new().unwrap().with_overrides([
419 ("first.example.com", vec![addr1]),
420 ("second.example.com", vec![addr2]),
421 ]);
422
423 let result1 = ScionDnsResolver::resolve(&resolver, "first.example.com")
424 .await
425 .unwrap();
426 assert_eq!(result1, vec![addr1]);
427
428 let result2 = ScionDnsResolver::resolve(&resolver, "second.example.com")
429 .await
430 .unwrap();
431 assert_eq!(result2, vec![addr2]);
432 }
433
434 #[tokio::test]
435 async fn with_override_later_call_replaces_previous() {
436 let addr1 = ScionIpAddr::from_str("19-ff00:0:110,192.0.2.1").unwrap();
437 let addr2 = ScionIpAddr::from_str("19-ff00:0:111,192.0.2.2").unwrap();
438 let resolver = ScionTxtDnsResolver::new()
439 .unwrap()
440 .with_override("example.com", vec![addr1])
441 .with_override("example.com", vec![addr2]);
442
443 let result = ScionDnsResolver::resolve(&resolver, "example.com")
444 .await
445 .unwrap();
446 assert_eq!(result, vec![addr2]);
447 }
448}