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::{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 scion_proto::address::{HostAddr, IsdAsn, ScionAddr};
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
55/// into `ScionAddr` values. Construction errors are reported via
56/// `TxtResolverError`, while lookup failures and parsing outcomes are reported
57/// through `ResolveError` from `ScionDnsResolver::resolve`.
58#[derive(Clone, Debug)]
59pub struct ScionTxtDnsResolver {
60    resolver: TokioResolver,
61}
62
63impl ScionTxtDnsResolver {
64    /// Create a resolver using the system DNS configuration.
65    ///
66    /// This uses the OS resolver configuration (for example `/etc/resolv.conf`)
67    /// and then applies the default hickory-dns options for lookups.
68    ///
69    /// # Errors
70    ///
71    /// Returns `TxtResolverError` if the system configuration cannot be loaded.
72    pub fn new() -> Result<Self, TxtResolverError> {
73        let builder = Self::builder()?;
74        Self::from_builder(builder)
75    }
76
77    /// Construct a resolver from a pre-configured hickory `ResolverBuilder`.
78    ///
79    /// This allows callers to customize resolver options (timeouts, retries,
80    /// name servers) via hickory-dns before constructing the resolver.
81    ///
82    /// # Errors
83    ///
84    /// This function is currently infallible, but returns `Result` for future
85    /// compatibility with hickory-dns builder changes.
86    pub fn from_builder(
87        builder: ResolverBuilder<TokioConnectionProvider>,
88    ) -> Result<Self, TxtResolverError> {
89        Ok(Self {
90            resolver: builder.build(),
91        })
92    }
93
94    /// Create a builder for configuring resolver options.
95    ///
96    /// On Linux/macOS the builder is initialized from the system DNS
97    /// configuration (`/etc/resolv.conf`). On Android and iOS, which do not
98    /// expose `/etc/resolv.conf`, Google Public DNS is used as a fallback.
99    ///
100    /// The returned builder can be adjusted before calling
101    /// `ScionTxtDnsResolver::from_builder`.
102    ///
103    /// # Errors
104    ///
105    /// Returns `TxtResolverError` if system configuration cannot be loaded
106    /// (non-Android/iOS platforms only).
107    pub fn builder() -> Result<ResolverBuilder<TokioConnectionProvider>, TxtResolverError> {
108        #[cfg(any(target_os = "android", target_os = "ios"))]
109        {
110            use hickory_resolver::config::ResolverConfig;
111            // Android and iOS do not have /etc/resolv.conf.
112            // Fall back to Google Public DNS for SCION TXT record resolution.
113            Ok(TokioResolver::builder_with_config(
114                ResolverConfig::google(),
115                TokioConnectionProvider::default(),
116            ))
117        }
118        #[cfg(not(any(target_os = "android", target_os = "ios")))]
119        {
120            Ok(TokioResolver::builder_tokio()?)
121        }
122    }
123}
124
125#[async_trait]
126impl ScionDnsResolver for ScionTxtDnsResolver {
127    async fn resolve(&self, domain: &str) -> Result<Vec<ScionAddr>, ResolveError> {
128        let lookup = self
129            .resolver
130            .txt_lookup(domain)
131            .await
132            .map_err(|err| ResolveError::DnsLookup(err.to_string()))?;
133
134        let mut txt_records = Vec::new();
135        let mut invalid_entries = Vec::new();
136        for txt in lookup.iter() {
137            match txt_record_to_string(txt) {
138                Ok(txt_record) => txt_records.push(txt_record),
139                Err(err) => invalid_entries.push(err),
140            }
141        }
142
143        resolve_txt_records_with_invalid(domain, txt_records, invalid_entries)
144    }
145}
146
147/// Errors returned while constructing a TXT resolver.
148#[derive(Debug, Error)]
149pub enum TxtResolverError {
150    /// DNS resolver configuration failed.
151    #[error("dns resolver configuration failed: {0}")]
152    DnsConfig(#[from] hickory_resolver::ResolveError),
153}
154
155impl PartialEq for TxtResolverError {
156    fn eq(&self, other: &Self) -> bool {
157        match (self, other) {
158            (Self::DnsConfig(a), Self::DnsConfig(b)) => a.to_string() == b.to_string(),
159        }
160    }
161}
162
163#[derive(Debug, Error)]
164enum TxtParseError {
165    #[error("missing TXT address list")]
166    MissingAddressList,
167    #[error("expected '[' at: {0}")]
168    ExpectedOpenBracket(String),
169    #[error("missing closing ']' in: {0}")]
170    MissingCloseBracket(String),
171    #[error("expected comma separator in: {0}")]
172    MissingSeparator(String),
173    #[error("invalid ISD-AS: {0}")]
174    InvalidIsdAsn(#[from] scion_proto::address::AddressParseError),
175    #[error("invalid host address: {0}")]
176    InvalidHost(#[from] std::net::AddrParseError),
177    #[error("expected ',' after entry in: {0}")]
178    ExpectedComma(String),
179}
180
181#[cfg(test)]
182fn resolve_txt_records(
183    domain: &str,
184    records: impl IntoIterator<Item = String>,
185) -> Result<Vec<ScionAddr>, ResolveError> {
186    resolve_txt_records_with_invalid(domain, records, Vec::new())
187}
188
189fn resolve_txt_records_with_invalid(
190    domain: &str,
191    records: impl IntoIterator<Item = String>,
192    mut invalid: Vec<InvalidEntry>,
193) -> Result<Vec<ScionAddr>, ResolveError> {
194    let mut valid = Vec::new();
195
196    for record in records {
197        let Some(payload) = record.strip_prefix(SCION_TXT_PREFIX) else {
198            continue;
199        };
200
201        match parse_txt_payload(payload) {
202            Ok(mut addresses) => valid.append(&mut addresses),
203            Err(err) => invalid.push(InvalidEntry::new(record, err.to_string())),
204        }
205    }
206
207    if valid.is_empty() {
208        return Err(ResolveError::NoValidEntries {
209            domain: domain.to_string(),
210            invalid_entries: invalid,
211        });
212    }
213
214    if !invalid.is_empty() {
215        let details = format_invalid_entries(&invalid);
216        tracing::info!(
217            domain,
218            invalid_entries = invalid.len(),
219            details = ?details,
220            "Ignoring invalid SCION TXT entries"
221        );
222    }
223
224    Ok(valid)
225}
226
227fn parse_txt_payload(payload: &str) -> Result<Vec<ScionAddr>, TxtParseError> {
228    let mut remaining = payload.trim();
229    if remaining.is_empty() {
230        return Err(TxtParseError::MissingAddressList);
231    }
232
233    let mut addresses = Vec::new();
234    while !remaining.is_empty() {
235        if !remaining.starts_with('[') {
236            return Err(TxtParseError::ExpectedOpenBracket(remaining.to_string()));
237        }
238
239        let close_idx = remaining
240            .find(']')
241            .ok_or_else(|| TxtParseError::MissingCloseBracket(remaining.to_string()))?;
242        let entry = remaining[1..close_idx].trim();
243        let rest = remaining[close_idx + 1..].trim();
244
245        let (isd_asn_str, host_str) = entry
246            .split_once(',')
247            .ok_or_else(|| TxtParseError::MissingSeparator(entry.to_string()))?;
248
249        let isd_asn = IsdAsn::from_str(isd_asn_str.trim())?;
250        let host = IpAddr::from_str(host_str.trim())?;
251
252        addresses.push(ScionAddr::new(isd_asn, HostAddr::from(host)));
253
254        if rest.is_empty() {
255            break;
256        }
257
258        if !rest.starts_with(',') {
259            return Err(TxtParseError::ExpectedComma(rest.to_string()));
260        }
261
262        remaining = rest[1..].trim();
263    }
264
265    Ok(addresses)
266}
267
268fn txt_record_to_string(txt: &TXT) -> Result<String, InvalidEntry> {
269    let bytes: Vec<u8> = txt
270        .txt_data()
271        .iter()
272        .flat_map(|chunk| chunk.iter())
273        .copied()
274        .collect();
275
276    String::from_utf8(bytes)
277        .map_err(|_| InvalidEntry::new("<invalid-utf8>", "TXT entry is not valid UTF-8"))
278}
279
280fn format_invalid_entries(entries: &[InvalidEntry]) -> Vec<String> {
281    entries
282        .iter()
283        .map(|entry| format!("{} ({})", entry.raw(), entry.reason()))
284        .collect()
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn parse_txt_payload_single() {
293        let addrs = parse_txt_payload("[19-ff00:0:110,192.0.2.1]").expect("valid payload");
294        assert_eq!(addrs.len(), 1);
295        assert_eq!(
296            addrs[0],
297            ScionAddr::from_str("19-ff00:0:110,192.0.2.1").unwrap()
298        );
299    }
300
301    #[test]
302    fn parse_txt_payload_multiple() {
303        let addrs = parse_txt_payload("[19-ff00:0:110,192.0.2.1],[19-ff00:0:111,2001:db8::1]")
304            .expect("valid payload");
305        assert_eq!(addrs.len(), 2);
306    }
307
308    #[test]
309    fn resolve_txt_records_mixed_validity() {
310        let records = vec![
311            "scion=v1;[19-ff00:0:110,192.0.2.1]".to_string(),
312            "scion=v1;[bad,192.0.2.2]".to_string(),
313        ];
314
315        let resolved = resolve_txt_records("example.com", records).expect("valid addresses");
316        assert_eq!(resolved.len(), 1);
317    }
318
319    #[test]
320    fn resolve_txt_records_no_valid_entries() {
321        let records = vec!["scion=v1;[bad,192.0.2.2]".to_string()];
322
323        let err = resolve_txt_records("example.com", records).expect_err("no valid entries");
324        match err {
325            ResolveError::NoValidEntries { domain, .. } => {
326                assert_eq!(domain, "example.com");
327            }
328            other => panic!("unexpected error: {other:?}"),
329        }
330    }
331
332    #[test]
333    fn parse_txt_payload_allows_whitespace_between_entries() {
334        let addrs = parse_txt_payload("[19-ff00:0:110,192.0.2.1] , [19-ff00:0:111,2001:db8::1]")
335            .expect("valid payload");
336        assert_eq!(addrs.len(), 2);
337    }
338}