anya_core/bip/
dns_resolver.rs1use hickory_resolver::{
4 config::{ResolverConfig, ResolverOpts},
5 error::ResolveError,
6 proto::rr::Name,
7 TokioAsyncResolver,
8};
9use std::collections::HashMap;
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12use thiserror::Error;
13use tokio::time::timeout;
14use tracing::{debug, error};
15
16const BITCOIN_SERVICE: &str = "_bitcoin";
18const WALLET_SERVICE: &str = "_wallet";
19const DNS_TIMEOUT_SECS: u64 = 5;
20
21#[derive(Error, Debug)]
23pub enum DnsResolverError {
24 #[error("DNS resolution error: {0}")]
25 Resolution(String),
26
27 #[error("Timeout error: {0}")]
28 Timeout(String),
29
30 #[error("DNSSEC validation error: {0}")]
31 DnssecValidation(String),
32
33 #[error("Record format error: {0}")]
34 RecordFormat(String),
35
36 #[error("No valid TXT records found")]
37 NoValidRecords,
38
39 #[error("DNS resolver initialization error: {0}")]
40 Initialization(String),
41}
42
43pub type DnsResult<T> = Result<T, DnsResolverError>;
45
46struct CacheEntry {
48 pub txt_records: Vec<String>,
49 pub expires_at: u64,
50 #[allow(dead_code)]
51 pub is_secure: bool,
52}
53
54pub struct DnsResolver {
56 resolver: TokioAsyncResolver,
57 cache: Arc<Mutex<HashMap<String, CacheEntry>>>,
58 validate_dnssec: bool,
59 cache_duration: u64, }
61
62impl DnsResolver {
63 pub async fn new(validate_dnssec: bool, cache_duration: u64) -> DnsResult<Self> {
65 let mut opts = ResolverOpts::default();
66 opts.validate = validate_dnssec;
67
68 let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), opts);
70
71 Ok(Self {
72 resolver,
73 cache: Arc::new(Mutex::new(HashMap::new())),
74 validate_dnssec,
75 cache_duration,
76 })
77 }
78
79 pub async fn resolve_payment_username(
81 &self,
82 user: &str,
83 domain: &str,
84 ) -> DnsResult<Vec<String>> {
85 let cache_key = format!("{user}@{domain}");
87
88 if let Some(cached) = self.check_cache(&cache_key) {
90 debug!("Using cached DNS records for {}", cache_key);
91 return Ok(cached);
92 }
93
94 let name = self.format_dns_name(user, domain)?;
96
97 debug!("Resolving DNS TXT records for {}", name);
98
99 let lookup_future = self.resolver.txt_lookup(name.clone());
101 let lookup_result =
102 match timeout(Duration::from_secs(DNS_TIMEOUT_SECS), lookup_future).await {
103 Ok(result) => result.map_err(|e| DnsResolverError::Resolution(e.to_string())),
104 Err(_) => Err(DnsResolverError::Timeout(format!(
105 "DNS lookup for {name} timed out"
106 ))),
107 }?;
108
109 let mut txt_records = Vec::new();
111 let mut is_secure = false;
112
113 for record in lookup_result.iter() {
115 for txt in record.iter() {
116 let txt_data = String::from_utf8_lossy(txt).to_string();
117 debug!("Found TXT record: {}", txt_data);
118 txt_records.push(txt_data);
119 }
120 }
121
122 if self.validate_dnssec {
124 debug!("DNSSEC validation enabled for {}", cache_key);
127 is_secure = true; }
129
130 if txt_records.is_empty() {
131 return Err(DnsResolverError::NoValidRecords);
132 }
133
134 self.cache_result(&cache_key, txt_records.clone(), is_secure);
136
137 Ok(txt_records)
138 }
139
140 fn format_dns_name(&self, user: &str, domain: &str) -> DnsResult<Name> {
142 let dns_name = format!("{BITCOIN_SERVICE}.{WALLET_SERVICE}.{user}.{domain}");
144
145 Name::from_ascii(&dns_name).map_err(|e| DnsResolverError::RecordFormat(e.to_string()))
146 }
147
148 fn check_cache(&self, cache_key: &str) -> Option<Vec<String>> {
150 let cache = self.cache.lock().unwrap();
151
152 if let Some(entry) = cache.get(cache_key) {
153 let now = SystemTime::now()
154 .duration_since(UNIX_EPOCH)
155 .unwrap()
156 .as_secs();
157
158 if entry.expires_at > now {
159 return Some(entry.txt_records.clone());
160 }
161 }
162
163 None
164 }
165
166 fn cache_result(&self, cache_key: &str, txt_records: Vec<String>, is_secure: bool) {
168 let now = SystemTime::now()
169 .duration_since(UNIX_EPOCH)
170 .unwrap()
171 .as_secs();
172
173 let expires_at = now + self.cache_duration;
174
175 let entry = CacheEntry {
176 txt_records,
177 expires_at,
178 is_secure,
179 };
180
181 let mut cache = self.cache.lock().unwrap();
182 cache.insert(cache_key.to_string(), entry);
183 }
184
185 pub fn clear_cache(&self) {
187 let mut cache = self.cache.lock().unwrap();
188 cache.clear();
189 }
190
191 pub fn update_config(&mut self, validate_dnssec: bool, cache_duration: u64) -> DnsResult<()> {
193 if self.validate_dnssec != validate_dnssec {
195 let mut opts = ResolverOpts::default();
196 opts.validate = validate_dnssec;
197
198 let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), opts);
200
201 self.resolver = resolver;
202 self.validate_dnssec = validate_dnssec;
203 }
204
205 self.cache_duration = cache_duration;
206
207 self.clear_cache();
209
210 Ok(())
211 }
212}
213
214pub fn parse_payment_instruction(txt_record: &str) -> Option<String> {
216 txt_record
220 .strip_prefix("bitcoin=")
221 .map(|value| value.to_string())
222}
223
224impl From<ResolveError> for DnsResolverError {
225 fn from(err: ResolveError) -> Self {
226 DnsResolverError::Resolution(err.to_string())
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use mockall::predicate::*;
234 use mockall::*;
235
236 mock! {
238 DnsResolver {
239 async fn resolve_payment_username(&self, user: &str, domain: &str) -> DnsResult<Vec<String>>;
240 fn format_dns_name(&self, user: &str, domain: &str) -> DnsResult<Name>;
241 fn check_cache(&self, cache_key: &str) -> Option<Vec<String>>;
242 fn cache_result(&self, cache_key: &str, txt_records: Vec<String>, is_secure: bool);
243 fn clear_cache(&self);
244 fn update_config(&mut self, validate_dnssec: bool, cache_duration: u64) -> DnsResult<()>;
245 }
246 }
247
248 #[tokio::test]
249 async fn test_parse_payment_instruction() {
250 let valid_txt = "bitcoin=lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns";
251 let invalid_txt = "not_bitcoin=something";
252
253 assert_eq!(
254 parse_payment_instruction(valid_txt),
255 Some("lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns".to_string())
256 );
257
258 assert_eq!(parse_payment_instruction(invalid_txt), None);
259 }
260
261 #[tokio::test]
262 async fn test_format_dns_name() {
263 let resolver = DnsResolver {
265 resolver: TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()),
266 cache: Arc::new(Mutex::new(HashMap::new())),
267 validate_dnssec: false,
268 cache_duration: 3600,
269 };
270
271 let name = resolver.format_dns_name("alice", "example.org").unwrap();
272 assert_eq!(name.to_string(), "_bitcoin._wallet.alice.example.org");
273 }
274}