1use std::{
36 collections::{BTreeMap, BTreeSet},
37 fmt::{self, Display},
38 hash::Hash,
39 net::SocketAddr,
40 str::FromStr,
41};
42
43use anyhow::{anyhow, Result};
44#[cfg(not(wasm_browser))]
45use hickory_resolver::{proto::ProtoError, Name};
46use iroh_base::{NodeAddr, NodeId, RelayUrl, SecretKey};
47#[cfg(not(wasm_browser))]
48use tracing::warn;
49use url::Url;
50
51#[cfg(not(wasm_browser))]
52use crate::{defaults::timeouts::DNS_TIMEOUT, dns::DnsResolver};
53
54pub const IROH_TXT_NAME: &str = "_iroh";
56
57pub trait NodeIdExt {
60 fn to_z32(&self) -> String;
64
65 fn from_z32(s: &str) -> Result<NodeId>;
69}
70
71impl NodeIdExt for NodeId {
72 fn to_z32(&self) -> String {
73 z32::encode(self.as_bytes())
74 }
75
76 fn from_z32(s: &str) -> Result<NodeId> {
77 let bytes = z32::decode(s.as_bytes()).map_err(|_| anyhow!("invalid z32"))?;
78 let bytes: &[u8; 32] = &bytes.try_into().map_err(|_| anyhow!("not 32 bytes long"))?;
79 let node_id = NodeId::from_bytes(bytes)?;
80 Ok(node_id)
81 }
82}
83
84#[derive(Debug, Clone, Default, Eq, PartialEq)]
93pub struct NodeData {
94 relay_url: Option<RelayUrl>,
96 direct_addresses: BTreeSet<SocketAddr>,
98 user_data: Option<UserData>,
100}
101
102impl NodeData {
103 pub fn new(relay_url: Option<RelayUrl>, direct_addresses: BTreeSet<SocketAddr>) -> Self {
105 Self {
106 relay_url,
107 direct_addresses,
108 user_data: None,
109 }
110 }
111
112 pub fn with_relay_url(mut self, relay_url: Option<RelayUrl>) -> Self {
114 self.relay_url = relay_url;
115 self
116 }
117
118 pub fn with_direct_addresses(mut self, direct_addresses: BTreeSet<SocketAddr>) -> Self {
120 self.direct_addresses = direct_addresses;
121 self
122 }
123
124 pub fn with_user_data(mut self, user_data: Option<UserData>) -> Self {
126 self.user_data = user_data;
127 self
128 }
129
130 pub fn relay_url(&self) -> Option<&RelayUrl> {
132 self.relay_url.as_ref()
133 }
134
135 pub fn user_data(&self) -> Option<&UserData> {
137 self.user_data.as_ref()
138 }
139
140 pub fn direct_addresses(&self) -> &BTreeSet<SocketAddr> {
142 &self.direct_addresses
143 }
144
145 pub fn clear_direct_addresses(&mut self) {
147 self.direct_addresses = Default::default();
148 }
149
150 pub fn add_direct_addresses(&mut self, addrs: impl IntoIterator<Item = SocketAddr>) {
152 self.direct_addresses.extend(addrs)
153 }
154
155 pub fn set_relay_url(&mut self, relay_url: Option<RelayUrl>) {
157 self.relay_url = relay_url
158 }
159
160 pub fn set_user_data(&mut self, user_data: Option<UserData>) {
162 self.user_data = user_data;
163 }
164}
165
166impl From<NodeAddr> for NodeData {
167 fn from(node_addr: NodeAddr) -> Self {
168 Self {
169 relay_url: node_addr.relay_url,
170 direct_addresses: node_addr.direct_addresses,
171 user_data: None,
172 }
173 }
174}
175
176#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
185pub struct UserData(String);
186
187impl UserData {
188 pub const MAX_LENGTH: usize = 245;
194}
195
196#[derive(Debug, thiserror::Error)]
198#[error("User-defined data exceeds max length")]
199pub struct MaxLengthExceededError;
200
201impl TryFrom<String> for UserData {
202 type Error = MaxLengthExceededError;
203
204 fn try_from(value: String) -> Result<Self, Self::Error> {
205 if value.len() > Self::MAX_LENGTH {
206 Err(MaxLengthExceededError)
207 } else {
208 Ok(Self(value))
209 }
210 }
211}
212
213impl FromStr for UserData {
214 type Err = MaxLengthExceededError;
215
216 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
217 if s.len() > Self::MAX_LENGTH {
218 Err(MaxLengthExceededError)
219 } else {
220 Ok(Self(s.to_string()))
221 }
222 }
223}
224
225impl fmt::Display for UserData {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 write!(f, "{}", self.0)
228 }
229}
230
231impl AsRef<str> for UserData {
232 fn as_ref(&self) -> &str {
233 &self.0
234 }
235}
236
237#[derive(derive_more::Debug, Clone, Eq, PartialEq)]
241pub struct NodeInfo {
242 pub node_id: NodeId,
244 pub data: NodeData,
246}
247
248impl From<TxtAttrs<IrohAttr>> for NodeInfo {
249 fn from(attrs: TxtAttrs<IrohAttr>) -> Self {
250 (&attrs).into()
251 }
252}
253
254impl From<&TxtAttrs<IrohAttr>> for NodeInfo {
255 fn from(attrs: &TxtAttrs<IrohAttr>) -> Self {
256 let node_id = attrs.node_id();
257 let attrs = attrs.attrs();
258 let relay_url = attrs
259 .get(&IrohAttr::Relay)
260 .into_iter()
261 .flatten()
262 .next()
263 .and_then(|s| Url::parse(s).ok());
264 let direct_addresses = attrs
265 .get(&IrohAttr::Addr)
266 .into_iter()
267 .flatten()
268 .filter_map(|s| SocketAddr::from_str(s).ok())
269 .collect();
270 let user_data = attrs
271 .get(&IrohAttr::UserData)
272 .into_iter()
273 .flatten()
274 .next()
275 .and_then(|s| UserData::from_str(s).ok());
276 let data = NodeData {
277 relay_url: relay_url.map(Into::into),
278 direct_addresses,
279 user_data,
280 };
281 Self { node_id, data }
282 }
283}
284
285impl From<NodeInfo> for NodeAddr {
286 fn from(value: NodeInfo) -> Self {
287 value.into_node_addr()
288 }
289}
290
291impl From<NodeAddr> for NodeInfo {
292 fn from(addr: NodeAddr) -> Self {
293 Self::new(addr.node_id)
294 .with_relay_url(addr.relay_url)
295 .with_direct_addresses(addr.direct_addresses)
296 }
297}
298
299impl NodeInfo {
300 pub fn new(node_id: NodeId) -> Self {
302 Self::from_parts(node_id, Default::default())
303 }
304
305 pub fn from_parts(node_id: NodeId, data: NodeData) -> Self {
307 Self { node_id, data }
308 }
309
310 pub fn with_relay_url(mut self, relay_url: Option<RelayUrl>) -> Self {
312 self.data = self.data.with_relay_url(relay_url);
313 self
314 }
315
316 pub fn with_direct_addresses(mut self, direct_addresses: BTreeSet<SocketAddr>) -> Self {
318 self.data = self.data.with_direct_addresses(direct_addresses);
319 self
320 }
321
322 pub fn with_user_data(mut self, user_data: Option<UserData>) -> Self {
324 self.data = self.data.with_user_data(user_data);
325 self
326 }
327
328 pub fn to_node_addr(&self) -> NodeAddr {
330 NodeAddr {
331 node_id: self.node_id,
332 relay_url: self.data.relay_url.clone(),
333 direct_addresses: self.data.direct_addresses.clone(),
334 }
335 }
336
337 pub fn into_node_addr(self) -> NodeAddr {
339 NodeAddr {
340 node_id: self.node_id,
341 relay_url: self.data.relay_url,
342 direct_addresses: self.data.direct_addresses,
343 }
344 }
345
346 fn to_attrs(&self) -> TxtAttrs<IrohAttr> {
347 self.into()
348 }
349
350 #[cfg(not(wasm_browser))]
351 pub fn from_txt_lookup(lookup: crate::dns::TxtLookup) -> Result<Self> {
353 let attrs = TxtAttrs::from_txt_lookup(lookup)?;
354 Ok(attrs.into())
355 }
356
357 pub fn from_pkarr_signed_packet(packet: &pkarr::SignedPacket) -> Result<Self> {
359 let attrs = TxtAttrs::from_pkarr_signed_packet(packet)?;
360 Ok(attrs.into())
361 }
362
363 pub fn to_pkarr_signed_packet(
367 &self,
368 secret_key: &SecretKey,
369 ttl: u32,
370 ) -> Result<pkarr::SignedPacket> {
371 self.to_attrs().to_pkarr_signed_packet(secret_key, ttl)
372 }
373
374 pub fn to_txt_strings(&self) -> Vec<String> {
376 self.to_attrs().to_txt_strings().collect()
377 }
378}
379
380impl std::ops::Deref for NodeInfo {
381 type Target = NodeData;
382 fn deref(&self) -> &Self::Target {
383 &self.data
384 }
385}
386
387impl std::ops::DerefMut for NodeInfo {
388 fn deref_mut(&mut self) -> &mut Self::Target {
389 &mut self.data
390 }
391}
392
393#[cfg(not(wasm_browser))]
399fn node_id_from_hickory_name(name: &hickory_resolver::proto::rr::Name) -> Option<NodeId> {
400 if name.num_labels() < 2 {
401 return None;
402 }
403 let mut labels = name.iter();
404 let label = std::str::from_utf8(labels.next().expect("num_labels checked")).ok()?;
405 if label != IROH_TXT_NAME {
406 return None;
407 }
408 let label = std::str::from_utf8(labels.next().expect("num_labels checked")).ok()?;
409 let node_id = NodeId::from_z32(label).ok()?;
410 Some(node_id)
411}
412
413#[derive(
417 Debug, strum::Display, strum::AsRefStr, strum::EnumString, Hash, Eq, PartialEq, Ord, PartialOrd,
418)]
419#[strum(serialize_all = "kebab-case")]
420pub(crate) enum IrohAttr {
421 Relay,
423 Addr,
425 UserData,
427}
428
429#[derive(Debug)]
435pub(crate) struct TxtAttrs<T> {
436 node_id: NodeId,
437 attrs: BTreeMap<T, Vec<String>>,
438}
439
440impl From<&NodeInfo> for TxtAttrs<IrohAttr> {
441 fn from(info: &NodeInfo) -> Self {
442 let mut attrs = vec![];
443 if let Some(relay_url) = &info.data.relay_url {
444 attrs.push((IrohAttr::Relay, relay_url.to_string()));
445 }
446 for addr in &info.data.direct_addresses {
447 attrs.push((IrohAttr::Addr, addr.to_string()));
448 }
449 if let Some(user_data) = &info.data.user_data {
450 attrs.push((IrohAttr::UserData, user_data.to_string()));
451 }
452 Self::from_parts(info.node_id, attrs.into_iter())
453 }
454}
455
456impl<T: FromStr + Display + Hash + Ord> TxtAttrs<T> {
457 pub(crate) fn from_parts(node_id: NodeId, pairs: impl Iterator<Item = (T, String)>) -> Self {
459 let mut attrs: BTreeMap<T, Vec<String>> = BTreeMap::new();
460 for (k, v) in pairs {
461 attrs.entry(k).or_default().push(v);
462 }
463 Self { attrs, node_id }
464 }
465
466 pub(crate) fn from_strings(
468 node_id: NodeId,
469 strings: impl Iterator<Item = String>,
470 ) -> Result<Self> {
471 let mut attrs: BTreeMap<T, Vec<String>> = BTreeMap::new();
472 for s in strings {
473 let mut parts = s.split('=');
474 let (Some(key), Some(value)) = (parts.next(), parts.next()) else {
475 continue;
476 };
477 let Ok(attr) = T::from_str(key) else {
478 continue;
479 };
480 attrs.entry(attr).or_default().push(value.to_string());
481 }
482 Ok(Self { attrs, node_id })
483 }
484
485 #[cfg(not(wasm_browser))]
486 async fn lookup(resolver: &DnsResolver, name: Name) -> Result<Self> {
487 let name = ensure_iroh_txt_label(name)?;
488 let lookup = resolver.lookup_txt(name, DNS_TIMEOUT).await?;
489 let attrs = Self::from_txt_lookup(lookup)?;
490 Ok(attrs)
491 }
492
493 #[cfg(not(wasm_browser))]
495 pub(crate) async fn lookup_by_id(
496 resolver: &DnsResolver,
497 node_id: &NodeId,
498 origin: &str,
499 ) -> Result<Self> {
500 let name = node_domain(node_id, origin)?;
501 TxtAttrs::lookup(resolver, name).await
502 }
503
504 #[cfg(not(wasm_browser))]
506 pub(crate) async fn lookup_by_name(resolver: &DnsResolver, name: &str) -> Result<Self> {
507 let name = Name::from_str(name)?;
508 TxtAttrs::lookup(resolver, name).await
509 }
510
511 pub(crate) fn attrs(&self) -> &BTreeMap<T, Vec<String>> {
513 &self.attrs
514 }
515
516 pub(crate) fn node_id(&self) -> NodeId {
518 self.node_id
519 }
520
521 pub(crate) fn from_pkarr_signed_packet(packet: &pkarr::SignedPacket) -> Result<Self> {
523 use pkarr::dns::{
524 rdata::RData,
525 {self},
526 };
527 let pubkey = packet.public_key();
528 let pubkey_z32 = pubkey.to_z32();
529 let node_id = NodeId::from(*pubkey.verifying_key());
530 let zone = dns::Name::new(&pubkey_z32)?;
531 let txt_data = packet
532 .all_resource_records()
533 .filter_map(|rr| match &rr.rdata {
534 RData::TXT(txt) => match rr.name.without(&zone) {
535 Some(name) if name.to_string() == IROH_TXT_NAME => Some(txt),
536 Some(_) | None => None,
537 },
538 _ => None,
539 });
540
541 let txt_strs = txt_data.filter_map(|s| String::try_from(s.clone()).ok());
542 Self::from_strings(node_id, txt_strs)
543 }
544
545 #[cfg(not(wasm_browser))]
547 pub(crate) fn from_txt_lookup(lookup: crate::dns::TxtLookup) -> Result<Self> {
548 let queried_node_id = node_id_from_hickory_name(lookup.0.query().name())
549 .ok_or_else(|| anyhow!("invalid DNS answer: not a query for _iroh.z32encodedpubkey"))?;
550
551 let strings = lookup.0.as_lookup().record_iter().filter_map(|record| {
552 match node_id_from_hickory_name(record.name()) {
553 Some(n) if n == queried_node_id => match record.data().as_txt() {
555 Some(txt) => Some(txt.to_string()),
556 None => {
557 warn!(
558 ?queried_node_id,
559 data = ?record.data(),
560 "unexpected record type for DNS discovery query"
561 );
562 None
563 }
564 },
565 Some(answered_node_id) => {
566 warn!(
567 ?queried_node_id,
568 ?answered_node_id,
569 "unexpected node ID answered for DNS query"
570 );
571 None
572 }
573 None => {
574 warn!(
575 ?queried_node_id,
576 name = ?record.name(),
577 "unexpected answer record name for DNS query"
578 );
579 None
580 }
581 }
582 });
583
584 Self::from_strings(queried_node_id, strings)
585 }
586
587 fn to_txt_strings(&self) -> impl Iterator<Item = String> + '_ {
588 self.attrs
589 .iter()
590 .flat_map(move |(k, vs)| vs.iter().map(move |v| format!("{k}={v}")))
591 }
592
593 pub(crate) fn to_pkarr_signed_packet(
597 &self,
598 secret_key: &SecretKey,
599 ttl: u32,
600 ) -> Result<pkarr::SignedPacket> {
601 use pkarr::dns::{self, rdata};
602 let keypair = pkarr::Keypair::from_secret_key(&secret_key.to_bytes());
603 let name = dns::Name::new(IROH_TXT_NAME)?;
604
605 let mut builder = pkarr::SignedPacket::builder();
606 for s in self.to_txt_strings() {
607 let mut txt = rdata::TXT::new();
608 txt.add_string(&s)?;
609 builder = builder.txt(name.clone(), txt.into_owned(), ttl);
610 }
611 let signed_packet = builder.build(&keypair)?;
612 Ok(signed_packet)
613 }
614}
615
616#[cfg(not(wasm_browser))]
617fn ensure_iroh_txt_label(name: Name) -> Result<Name, ProtoError> {
618 if name.iter().next() == Some(IROH_TXT_NAME.as_bytes()) {
619 Ok(name)
620 } else {
621 Name::parse(IROH_TXT_NAME, Some(&name))
622 }
623}
624
625#[cfg(not(wasm_browser))]
626fn node_domain(node_id: &NodeId, origin: &str) -> Result<Name> {
627 let domain = format!("{}.{}", NodeId::to_z32(node_id), origin);
628 let domain = Name::from_str(&domain)?;
629 Ok(domain)
630}
631
632#[cfg(test)]
633mod tests {
634 use std::{collections::BTreeSet, str::FromStr, sync::Arc};
635
636 use hickory_resolver::{
637 lookup::Lookup,
638 proto::{
639 op::Query,
640 rr::{
641 rdata::{A, TXT},
642 RData, Record, RecordType,
643 },
644 },
645 Name,
646 };
647 use iroh_base::{NodeId, SecretKey};
648 use testresult::TestResult;
649
650 use super::{NodeData, NodeIdExt, NodeInfo};
651
652 #[test]
653 fn txt_attr_roundtrip() {
654 let node_data = NodeData::new(
655 Some("https://example.com".parse().unwrap()),
656 ["127.0.0.1:1234".parse().unwrap()].into_iter().collect(),
657 )
658 .with_user_data(Some("foobar".parse().unwrap()));
659 let node_id = "vpnk377obfvzlipnsfbqba7ywkkenc4xlpmovt5tsfujoa75zqia"
660 .parse()
661 .unwrap();
662 let expected = NodeInfo::from_parts(node_id, node_data);
663 let attrs = expected.to_attrs();
664 let actual = NodeInfo::from(&attrs);
665 assert_eq!(expected, actual);
666 }
667
668 #[test]
669 fn signed_packet_roundtrip() {
670 let secret_key =
671 SecretKey::from_str("vpnk377obfvzlipnsfbqba7ywkkenc4xlpmovt5tsfujoa75zqia").unwrap();
672 let node_data = NodeData::new(
673 Some("https://example.com".parse().unwrap()),
674 ["127.0.0.1:1234".parse().unwrap()].into_iter().collect(),
675 )
676 .with_user_data(Some("foobar".parse().unwrap()));
677 let expected = NodeInfo::from_parts(secret_key.public(), node_data);
678 let packet = expected.to_pkarr_signed_packet(&secret_key, 30).unwrap();
679 let actual = NodeInfo::from_pkarr_signed_packet(&packet).unwrap();
680 assert_eq!(expected, actual);
681 }
682
683 #[test]
690 fn test_from_hickory_lookup() -> TestResult {
691 let name = Name::from_utf8(
692 "_iroh.dgjpkxyn3zyrk3zfads5duwdgbqpkwbjxfj4yt7rezidr3fijccy.dns.iroh.link.",
693 )?;
694 let query = Query::query(name.clone(), RecordType::TXT);
695 let records = [
696 Record::from_rdata(
697 name.clone(),
698 30,
699 RData::TXT(TXT::new(vec!["addr=192.168.96.145:60165".to_string()])),
700 ),
701 Record::from_rdata(
702 name.clone(),
703 30,
704 RData::TXT(TXT::new(vec!["addr=213.208.157.87:60165".to_string()])),
705 ),
706 Record::from_rdata(name.clone(), 30, RData::A(A::new(127, 0, 0, 1))),
708 Record::from_rdata(
710 Name::from_utf8(format!(
711 "_iroh.{}.dns.iroh.link.",
712 NodeId::from_str(
713 "a55f26132e5e43de834d534332f66a20d480c3e50a13a312a071adea6569981e"
715 )?
716 .to_z32()
717 ))?,
718 30,
719 RData::TXT(TXT::new(vec![
720 "relay=https://euw1-1.relay.iroh.network./".to_string()
721 ])),
722 ),
723 Record::from_rdata(
725 Name::from_utf8("dns.iroh.link.")?,
726 30,
727 RData::TXT(TXT::new(vec![
728 "relay=https://euw1-1.relay.iroh.network./".to_string()
729 ])),
730 ),
731 Record::from_rdata(
732 name.clone(),
733 30,
734 RData::TXT(TXT::new(vec![
735 "relay=https://euw1-1.relay.iroh.network./".to_string()
736 ])),
737 ),
738 ];
739 let lookup = Lookup::new_with_max_ttl(query, Arc::new(records));
740 let lookup = hickory_resolver::lookup::TxtLookup::from(lookup);
741
742 let node_info = NodeInfo::from_txt_lookup(lookup.into())?;
743
744 let expected_node_info = NodeInfo::new(NodeId::from_str(
745 "1992d53c02cdc04566e5c0edb1ce83305cd550297953a047a445ea3264b54b18",
746 )?)
747 .with_relay_url(Some("https://euw1-1.relay.iroh.network./".parse()?))
748 .with_direct_addresses(BTreeSet::from([
749 "192.168.96.145:60165".parse()?,
750 "213.208.157.87:60165".parse()?,
751 ]));
752
753 assert_eq!(node_info, expected_node_info);
754
755 Ok(())
756 }
757}