use std::{net::IpAddr, str::FromStr};
use async_trait::async_trait;
use hickory_resolver::{
ResolverBuilder, TokioResolver, name_server::TokioConnectionProvider, proto::rr::rdata::TXT,
};
use scion_proto::address::{HostAddr, IsdAsn, ScionAddr};
use thiserror::Error;
use super::{InvalidEntry, ResolveError, ScionDnsResolver};
const SCION_TXT_PREFIX: &str = "scion=v1;";
#[derive(Clone, Debug)]
pub struct ScionTxtDnsResolver {
resolver: TokioResolver,
}
impl ScionTxtDnsResolver {
pub fn new() -> Result<Self, TxtResolverError> {
let builder = Self::builder()?;
Self::from_builder(builder)
}
pub fn from_builder(
builder: ResolverBuilder<TokioConnectionProvider>,
) -> Result<Self, TxtResolverError> {
Ok(Self {
resolver: builder.build(),
})
}
pub fn builder() -> Result<ResolverBuilder<TokioConnectionProvider>, TxtResolverError> {
#[cfg(any(target_os = "android", target_os = "ios"))]
{
use hickory_resolver::config::ResolverConfig;
Ok(TokioResolver::builder_with_config(
ResolverConfig::google(),
TokioConnectionProvider::default(),
))
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
Ok(TokioResolver::builder_tokio()?)
}
}
}
#[async_trait]
impl ScionDnsResolver for ScionTxtDnsResolver {
async fn resolve(&self, domain: &str) -> Result<Vec<ScionAddr>, ResolveError> {
let lookup = self
.resolver
.txt_lookup(domain)
.await
.map_err(|err| ResolveError::DnsLookup(err.to_string()))?;
let mut txt_records = Vec::new();
let mut invalid_entries = Vec::new();
for txt in lookup.iter() {
match txt_record_to_string(txt) {
Ok(txt_record) => txt_records.push(txt_record),
Err(err) => invalid_entries.push(err),
}
}
resolve_txt_records_with_invalid(domain, txt_records, invalid_entries)
}
}
#[derive(Debug, Error)]
pub enum TxtResolverError {
#[error("dns resolver configuration failed: {0}")]
DnsConfig(#[from] hickory_resolver::ResolveError),
}
impl PartialEq for TxtResolverError {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::DnsConfig(a), Self::DnsConfig(b)) => a.to_string() == b.to_string(),
}
}
}
#[derive(Debug, Error)]
enum TxtParseError {
#[error("missing TXT address list")]
MissingAddressList,
#[error("expected '[' at: {0}")]
ExpectedOpenBracket(String),
#[error("missing closing ']' in: {0}")]
MissingCloseBracket(String),
#[error("expected comma separator in: {0}")]
MissingSeparator(String),
#[error("invalid ISD-AS: {0}")]
InvalidIsdAsn(#[from] scion_proto::address::AddressParseError),
#[error("invalid host address: {0}")]
InvalidHost(#[from] std::net::AddrParseError),
#[error("expected ',' after entry in: {0}")]
ExpectedComma(String),
}
#[cfg(test)]
fn resolve_txt_records(
domain: &str,
records: impl IntoIterator<Item = String>,
) -> Result<Vec<ScionAddr>, ResolveError> {
resolve_txt_records_with_invalid(domain, records, Vec::new())
}
fn resolve_txt_records_with_invalid(
domain: &str,
records: impl IntoIterator<Item = String>,
mut invalid: Vec<InvalidEntry>,
) -> Result<Vec<ScionAddr>, ResolveError> {
let mut valid = Vec::new();
for record in records {
let Some(payload) = record.strip_prefix(SCION_TXT_PREFIX) else {
continue;
};
match parse_txt_payload(payload) {
Ok(mut addresses) => valid.append(&mut addresses),
Err(err) => invalid.push(InvalidEntry::new(record, err.to_string())),
}
}
if valid.is_empty() {
return Err(ResolveError::NoValidEntries {
domain: domain.to_string(),
invalid_entries: invalid,
});
}
if !invalid.is_empty() {
let details = format_invalid_entries(&invalid);
tracing::info!(
domain,
invalid_entries = invalid.len(),
details = ?details,
"Ignoring invalid SCION TXT entries"
);
}
Ok(valid)
}
fn parse_txt_payload(payload: &str) -> Result<Vec<ScionAddr>, TxtParseError> {
let mut remaining = payload.trim();
if remaining.is_empty() {
return Err(TxtParseError::MissingAddressList);
}
let mut addresses = Vec::new();
while !remaining.is_empty() {
if !remaining.starts_with('[') {
return Err(TxtParseError::ExpectedOpenBracket(remaining.to_string()));
}
let close_idx = remaining
.find(']')
.ok_or_else(|| TxtParseError::MissingCloseBracket(remaining.to_string()))?;
let entry = remaining[1..close_idx].trim();
let rest = remaining[close_idx + 1..].trim();
let (isd_asn_str, host_str) = entry
.split_once(',')
.ok_or_else(|| TxtParseError::MissingSeparator(entry.to_string()))?;
let isd_asn = IsdAsn::from_str(isd_asn_str.trim())?;
let host = IpAddr::from_str(host_str.trim())?;
addresses.push(ScionAddr::new(isd_asn, HostAddr::from(host)));
if rest.is_empty() {
break;
}
if !rest.starts_with(',') {
return Err(TxtParseError::ExpectedComma(rest.to_string()));
}
remaining = rest[1..].trim();
}
Ok(addresses)
}
fn txt_record_to_string(txt: &TXT) -> Result<String, InvalidEntry> {
let bytes: Vec<u8> = txt
.txt_data()
.iter()
.flat_map(|chunk| chunk.iter())
.copied()
.collect();
String::from_utf8(bytes)
.map_err(|_| InvalidEntry::new("<invalid-utf8>", "TXT entry is not valid UTF-8"))
}
fn format_invalid_entries(entries: &[InvalidEntry]) -> Vec<String> {
entries
.iter()
.map(|entry| format!("{} ({})", entry.raw(), entry.reason()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_txt_payload_single() {
let addrs = parse_txt_payload("[19-ff00:0:110,192.0.2.1]").expect("valid payload");
assert_eq!(addrs.len(), 1);
assert_eq!(
addrs[0],
ScionAddr::from_str("19-ff00:0:110,192.0.2.1").unwrap()
);
}
#[test]
fn parse_txt_payload_multiple() {
let addrs = parse_txt_payload("[19-ff00:0:110,192.0.2.1],[19-ff00:0:111,2001:db8::1]")
.expect("valid payload");
assert_eq!(addrs.len(), 2);
}
#[test]
fn resolve_txt_records_mixed_validity() {
let records = vec![
"scion=v1;[19-ff00:0:110,192.0.2.1]".to_string(),
"scion=v1;[bad,192.0.2.2]".to_string(),
];
let resolved = resolve_txt_records("example.com", records).expect("valid addresses");
assert_eq!(resolved.len(), 1);
}
#[test]
fn resolve_txt_records_no_valid_entries() {
let records = vec!["scion=v1;[bad,192.0.2.2]".to_string()];
let err = resolve_txt_records("example.com", records).expect_err("no valid entries");
match err {
ResolveError::NoValidEntries { domain, .. } => {
assert_eq!(domain, "example.com");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn parse_txt_payload_allows_whitespace_between_entries() {
let addrs = parse_txt_payload("[19-ff00:0:110,192.0.2.1] , [19-ff00:0:111,2001:db8::1]")
.expect("valid payload");
assert_eq!(addrs.len(), 2);
}
}