use hickory_resolver::{
config::{ResolverConfig, ResolverOpts},
error::ResolveError,
proto::rr::Name,
TokioAsyncResolver,
};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use thiserror::Error;
use tokio::time::timeout;
use tracing::{debug, error};
const BITCOIN_SERVICE: &str = "_bitcoin";
const WALLET_SERVICE: &str = "_wallet";
const DNS_TIMEOUT_SECS: u64 = 5;
#[derive(Error, Debug)]
pub enum DnsResolverError {
#[error("DNS resolution error: {0}")]
Resolution(String),
#[error("Timeout error: {0}")]
Timeout(String),
#[error("DNSSEC validation error: {0}")]
DnssecValidation(String),
#[error("Record format error: {0}")]
RecordFormat(String),
#[error("No valid TXT records found")]
NoValidRecords,
#[error("DNS resolver initialization error: {0}")]
Initialization(String),
}
pub type DnsResult<T> = Result<T, DnsResolverError>;
struct CacheEntry {
pub txt_records: Vec<String>,
pub expires_at: u64,
#[allow(dead_code)]
pub is_secure: bool,
}
pub struct DnsResolver {
resolver: TokioAsyncResolver,
cache: Arc<Mutex<HashMap<String, CacheEntry>>>,
validate_dnssec: bool,
cache_duration: u64, }
impl DnsResolver {
pub async fn new(validate_dnssec: bool, cache_duration: u64) -> DnsResult<Self> {
let mut opts = ResolverOpts::default();
opts.validate = validate_dnssec;
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), opts);
Ok(Self {
resolver,
cache: Arc::new(Mutex::new(HashMap::new())),
validate_dnssec,
cache_duration,
})
}
pub async fn resolve_payment_username(
&self,
user: &str,
domain: &str,
) -> DnsResult<Vec<String>> {
let cache_key = format!("{user}@{domain}");
if let Some(cached) = self.check_cache(&cache_key) {
debug!("Using cached DNS records for {}", cache_key);
return Ok(cached);
}
let name = self.format_dns_name(user, domain)?;
debug!("Resolving DNS TXT records for {}", name);
let lookup_future = self.resolver.txt_lookup(name.clone());
let lookup_result =
match timeout(Duration::from_secs(DNS_TIMEOUT_SECS), lookup_future).await {
Ok(result) => result.map_err(|e| DnsResolverError::Resolution(e.to_string())),
Err(_) => Err(DnsResolverError::Timeout(format!(
"DNS lookup for {name} timed out"
))),
}?;
let mut txt_records = Vec::new();
let mut is_secure = false;
for record in lookup_result.iter() {
for txt in record.iter() {
let txt_data = String::from_utf8_lossy(txt).to_string();
debug!("Found TXT record: {}", txt_data);
txt_records.push(txt_data);
}
}
if self.validate_dnssec {
debug!("DNSSEC validation enabled for {}", cache_key);
is_secure = true; }
if txt_records.is_empty() {
return Err(DnsResolverError::NoValidRecords);
}
self.cache_result(&cache_key, txt_records.clone(), is_secure);
Ok(txt_records)
}
fn format_dns_name(&self, user: &str, domain: &str) -> DnsResult<Name> {
let dns_name = format!("{BITCOIN_SERVICE}.{WALLET_SERVICE}.{user}.{domain}");
Name::from_ascii(&dns_name).map_err(|e| DnsResolverError::RecordFormat(e.to_string()))
}
fn check_cache(&self, cache_key: &str) -> Option<Vec<String>> {
let cache = self.cache.lock().unwrap();
if let Some(entry) = cache.get(cache_key) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if entry.expires_at > now {
return Some(entry.txt_records.clone());
}
}
None
}
fn cache_result(&self, cache_key: &str, txt_records: Vec<String>, is_secure: bool) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let expires_at = now + self.cache_duration;
let entry = CacheEntry {
txt_records,
expires_at,
is_secure,
};
let mut cache = self.cache.lock().unwrap();
cache.insert(cache_key.to_string(), entry);
}
pub fn clear_cache(&self) {
let mut cache = self.cache.lock().unwrap();
cache.clear();
}
pub fn update_config(&mut self, validate_dnssec: bool, cache_duration: u64) -> DnsResult<()> {
if self.validate_dnssec != validate_dnssec {
let mut opts = ResolverOpts::default();
opts.validate = validate_dnssec;
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), opts);
self.resolver = resolver;
self.validate_dnssec = validate_dnssec;
}
self.cache_duration = cache_duration;
self.clear_cache();
Ok(())
}
}
pub fn parse_payment_instruction(txt_record: &str) -> Option<String> {
txt_record
.strip_prefix("bitcoin=")
.map(|value| value.to_string())
}
impl From<ResolveError> for DnsResolverError {
fn from(err: ResolveError) -> Self {
DnsResolverError::Resolution(err.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use mockall::predicate::*;
use mockall::*;
mock! {
DnsResolver {
async fn resolve_payment_username(&self, user: &str, domain: &str) -> DnsResult<Vec<String>>;
fn format_dns_name(&self, user: &str, domain: &str) -> DnsResult<Name>;
fn check_cache(&self, cache_key: &str) -> Option<Vec<String>>;
fn cache_result(&self, cache_key: &str, txt_records: Vec<String>, is_secure: bool);
fn clear_cache(&self);
fn update_config(&mut self, validate_dnssec: bool, cache_duration: u64) -> DnsResult<()>;
}
}
#[tokio::test]
async fn test_parse_payment_instruction() {
let valid_txt = "bitcoin=lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns";
let invalid_txt = "not_bitcoin=something";
assert_eq!(
parse_payment_instruction(valid_txt),
Some("lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns".to_string())
);
assert_eq!(parse_payment_instruction(invalid_txt), None);
}
#[tokio::test]
async fn test_format_dns_name() {
let resolver = DnsResolver {
resolver: TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()),
cache: Arc::new(Mutex::new(HashMap::new())),
validate_dnssec: false,
cache_duration: 3600,
};
let name = resolver.format_dns_name("alice", "example.org").unwrap();
assert_eq!(name.to_string(), "_bitcoin._wallet.alice.example.org");
}
}