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    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/// Resolver that interprets TXT records using the TSAR format.
53///
54/// Use this resolver to look up `scion=v1;...` TXT records and translate them into `ScionIpAddr`
55/// values. Construction errors are reported via `TxtResolverError`, while lookup failures and
56/// parsing outcomes are reported through `ResolveError` from `ScionDnsResolver::resolve`.
57///
58/// Domain-specific overrides can be added to bypass DNS and always return the configured addresses.
59#[derive(Clone, Debug)]
60pub struct ScionTxtDnsResolver {
61    resolver: TokioResolver,
62    overrides: HashMap<String, Vec<ScionIpAddr>>,
63}
64
65impl ScionTxtDnsResolver {
66    /// Create a resolver using the system DNS configuration.
67    ///
68    /// This uses the OS resolver configuration (for example `/etc/resolv.conf`)
69    /// and then applies the default hickory-dns options for lookups.
70    ///
71    /// # Errors
72    ///
73    /// Returns `TxtResolverError` if the system configuration cannot be loaded.
74    pub fn new() -> Result<Self, TxtResolverError> {
75        let builder = Self::builder()?;
76        Self::from_builder(builder)
77    }
78
79    /// Override DNS resolution for a specific domain.
80    #[must_use]
81    pub fn with_override(self, domain: &str, addrs: Vec<ScionIpAddr>) -> Self {
82        self.with_overrides(vec![(domain, addrs)])
83    }
84
85    /// Override DNS resolution for multiple domains at once.
86    #[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    /// Constructs a resolver from a pre-configured hickory `ResolverBuilder`.
99    ///
100    /// This allows callers to customize resolver options (timeouts, retries, name servers) via
101    /// hickory-dns before constructing the resolver.
102    ///
103    /// # Errors
104    ///
105    /// This function is currently infallible, but returns `Result` for future compatibility with
106    /// hickory-dns builder changes.
107    // Intentionally exposes hickory-dns's builder type as the escape hatch for full control over
108    // DNS resolution. See API_CONVENTIONS.md.
109    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    /// Creates a builder for configuring resolver options.
119    ///
120    /// On Linux/macOS the builder is initialized from the system DNS
121    /// configuration (`/etc/resolv.conf`). On Android and iOS, which do not
122    /// expose `/etc/resolv.conf`, Google Public DNS is used as a fallback.
123    ///
124    /// The returned builder can be adjusted before calling
125    /// [`ScionTxtDnsResolver::from_builder`].
126    ///
127    /// # Errors
128    ///
129    /// Returns [`TxtResolverError`] if system configuration cannot be loaded (non-Android/iOS
130    /// platforms only).
131    pub fn builder() -> Result<ResolverBuilder<TokioConnectionProvider>, TxtResolverError> {
132        #[cfg(any(target_os = "android", target_os = "ios"))]
133        {
134            use hickory_resolver::config::ResolverConfig;
135            // Android and iOS do not have /etc/resolv.conf.
136            // Fall back to Google Public DNS for SCION TXT record resolution.
137            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/// Errors returned while constructing a TXT resolver.
176///
177/// The underlying cause is available through [`std::error::Error::source`]; the concrete source
178/// type is intentionally not exposed, so the DNS backend can change without breaking the public
179/// API.
180#[derive(Debug, Error)]
181#[non_exhaustive]
182pub enum TxtResolverError {
183    /// DNS resolver configuration failed.
184    #[error("dns resolver configuration failed: {message}")]
185    DnsConfig {
186        /// Human-readable description of the configuration failure.
187        message: String,
188        /// The underlying cause.
189        #[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}