1use 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#[derive(Clone, Debug)]
59pub struct ScionTxtDnsResolver {
60 resolver: TokioResolver,
61}
62
63impl ScionTxtDnsResolver {
64 pub fn new() -> Result<Self, TxtResolverError> {
73 let builder = Self::builder()?;
74 Self::from_builder(builder)
75 }
76
77 pub fn from_builder(
87 builder: ResolverBuilder<TokioConnectionProvider>,
88 ) -> Result<Self, TxtResolverError> {
89 Ok(Self {
90 resolver: builder.build(),
91 })
92 }
93
94 pub fn builder() -> Result<ResolverBuilder<TokioConnectionProvider>, TxtResolverError> {
108 #[cfg(any(target_os = "android", target_os = "ios"))]
109 {
110 use hickory_resolver::config::ResolverConfig;
111 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#[derive(Debug, Error)]
149pub enum TxtResolverError {
150 #[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}