Skip to main content

scion_stack/resolver/
txt.rs

1// Copyright 2026 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//! TXT-based SCION address resolution (TSAR).
15//!
16//! TSAR encodes SCION addresses in DNS TXT records to support dual-stack
17//! resolution. The record format is defined as:
18//!
19//! ```text
20//! scion-txt     = "scion=" version separator address-list
21//! version       = "v1"          ; Versioning for future extensibility
22//! separator     = ";"
23//! address-list  = address *( "," address )
24//! address       = "[" isd-as "," host "]"
25//! isd-as        = 1*DIGIT "-" 1*HEXDIG ":" 1*HEXDIG ":" 1*HEXDIG
26//! host          = ipv4-address / ipv6-address
27//! ipv4-address  = 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT
28//! ipv6-address  = <RFC5952 compliant string>
29//! ```
30//!
31//! Example records:
32//!
33//! ```text
34//! example.com. IN TXT "scion=v1;[19-ff00:0:110,192.0.2.1]"
35//! example.com. IN TXT "scion=v1;[19-ff00:0:110,2001:db8::1]"
36//! example.com. IN TXT "scion=v1;[19-ff00:0:110,192.0.2.1],[19-ff00:0:111,203.0.113.5]"
37//! ```
38
39use 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/// Resolver that interprets TXT records using the TSAR format.
54///
55/// Use this resolver to look up `scion=v1;...` TXT records and translate them into `ScionIpAddr`
56/// values. Construction errors are reported via `TxtResolverError`, while lookup failures and
57/// parsing outcomes are reported through `ResolveError` from `ScionDnsResolver::resolve`.
58///
59/// Domain-specific overrides can be added to bypass DNS and always return the configured addresses.
60///
61/// # Caching
62///
63/// This type holds no cache of its own. Lookups are cached by the DNS core underneath, at the
64/// records' own TTLs, and that cache covers negative answers too: a name without TXT records is
65/// remembered for the negative TTL of the zone's SOA. Clones share that cache; dropping the last
66/// clone drops the cache with it.
67///
68/// Callers who need different bounds, or no caching at all, set `ResolverOpts` (`cache_size`,
69/// `positive_min_ttl`, `positive_max_ttl`, `negative_min_ttl`, `negative_max_ttl`) on the builder
70/// returned by [`ScionTxtDnsResolver::builder`] before passing it to
71/// [`ScionTxtDnsResolver::from_builder`]. Bounding staleness means bounding it there; a cache added
72/// above this type could not do so, because on expiry it would only re-read the copy the DNS core
73/// still holds.
74#[derive(Clone, Debug)]
75pub struct ScionTxtDnsResolver {
76    resolver: TokioResolver,
77    overrides: HashMap<String, Vec<ScionIpAddr>>,
78}
79
80impl ScionTxtDnsResolver {
81    /// Create a resolver using the system DNS configuration.
82    ///
83    /// This uses the OS resolver configuration (for example `/etc/resolv.conf`)
84    /// and then applies the default hickory-dns options for lookups.
85    ///
86    /// # Errors
87    ///
88    /// Returns `TxtResolverError` if the system configuration cannot be loaded.
89    pub fn new() -> Result<Self, TxtResolverError> {
90        let builder = Self::builder()?;
91        Self::from_builder(builder)
92    }
93
94    /// Override DNS resolution for a specific domain.
95    #[must_use]
96    pub fn with_override(self, domain: &str, addrs: Vec<ScionIpAddr>) -> Self {
97        self.with_overrides(vec![(domain, addrs)])
98    }
99
100    /// Override DNS resolution for multiple domains at once.
101    #[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    /// Constructs a resolver from a pre-configured hickory `ResolverBuilder`.
114    ///
115    /// This allows callers to customize resolver options (timeouts, retries, name servers) via
116    /// hickory-dns before constructing the resolver.
117    ///
118    /// # Errors
119    ///
120    /// This function is currently infallible, but returns `Result` for future compatibility with
121    /// hickory-dns builder changes.
122    // Intentionally exposes hickory-dns's builder type as the escape hatch for full control over
123    // DNS resolution. See API_CONVENTIONS.md.
124    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    /// Creates a builder for configuring resolver options.
134    ///
135    /// On Linux/macOS the builder is initialized from the system DNS
136    /// configuration (`/etc/resolv.conf`). On Android and iOS, which do not
137    /// expose `/etc/resolv.conf`, Google Public DNS is used as a fallback.
138    ///
139    /// The returned builder can be adjusted before calling
140    /// [`ScionTxtDnsResolver::from_builder`].
141    ///
142    /// # Errors
143    ///
144    /// Returns [`TxtResolverError`] if system configuration cannot be loaded (non-Android/iOS
145    /// platforms only).
146    pub fn builder() -> Result<ResolverBuilder<TokioConnectionProvider>, TxtResolverError> {
147        #[cfg(any(target_os = "android", target_os = "ios"))]
148        {
149            use hickory_resolver::config::ResolverConfig;
150            // Android and iOS do not have /etc/resolv.conf.
151            // Fall back to Google Public DNS for SCION TXT record resolution.
152            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/// Errors returned while constructing a TXT resolver.
191///
192/// The underlying cause is available through [`std::error::Error::source`]; the concrete source
193/// type is intentionally not exposed, so the DNS backend can change without breaking the public
194/// API.
195#[derive(Debug, Error)]
196#[non_exhaustive]
197pub enum TxtResolverError {
198    /// DNS resolver configuration failed.
199    #[error("dns resolver configuration failed: {message}")]
200    DnsConfig {
201        /// Human-readable description of the configuration failure.
202        message: String,
203        /// The underlying cause.
204        #[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
226/// Splits a hickory lookup failure into the two outcomes of the [`ScionDnsResolver`] contract: a
227/// lookup that completed but found no TXT records is a permanent [`ResolveError::NoValidEntries`]
228/// verdict, every other failure (timeout, network error, SERVFAIL) is a transient
229/// [`ResolveError::DnsLookup`] that a retry may resolve.
230///
231/// Hickory reports both an empty answer and a non-existent name as `NoRecordsFound`. Both are
232/// permanent for as long as the negative answer is valid, so the distinction between "no such name"
233/// and "the name exists without TXT records" is not carried over. Failure kinds we do not recognize
234/// stay transient, which is the safe default for a retry decision.
235fn 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    /// Builds the failure hickory reports for a TXT query that found no records, which is also how
382    /// it reports a name that does not exist (`response_code` tells the two apart).
383    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}