1use std::{
36 collections::{BTreeMap, BTreeSet},
37 fmt::{self, Display},
38 hash::Hash,
39 net::SocketAddr,
40 str::{FromStr, Utf8Error},
41};
42
43#[cfg(not(wasm_browser))]
44use hickory_resolver::{Name, proto::ProtoError};
45use iroh_base::{NodeAddr, NodeId, RelayUrl, SecretKey, SignatureError};
46use nested_enum_utils::common_fields;
47use snafu::{Backtrace, ResultExt, Snafu};
48#[cfg(not(wasm_browser))]
49use tracing::warn;
50use url::Url;
51
52#[cfg(not(wasm_browser))]
53use crate::{
54 defaults::timeouts::DNS_TIMEOUT,
55 dns::{DnsError, DnsResolver},
56};
57
58pub const IROH_TXT_NAME: &str = "_iroh";
60
61#[common_fields({
62 backtrace: Option<Backtrace>,
63 #[snafu(implicit)]
64 span_trace: n0_snafu::SpanTrace,
65})]
66#[allow(missing_docs)]
67#[derive(Debug, Snafu)]
68#[non_exhaustive]
69#[snafu(visibility(pub(crate)))]
70pub enum EncodingError {
71 #[snafu(transparent)]
72 FailedBuildingPacket {
73 source: pkarr::errors::SignedPacketBuildError,
74 },
75 #[snafu(display("invalid TXT entry"))]
76 InvalidTxtEntry { source: pkarr::dns::SimpleDnsError },
77}
78
79#[common_fields({
80 backtrace: Option<Backtrace>,
81 #[snafu(implicit)]
82 span_trace: n0_snafu::SpanTrace,
83})]
84#[allow(missing_docs)]
85#[derive(Debug, Snafu)]
86#[non_exhaustive]
87#[snafu(visibility(pub(crate)))]
88pub enum DecodingError {
89 #[snafu(display("node id was not encoded in valid z32"))]
90 InvalidEncodingZ32 { source: z32::Z32Error },
91 #[snafu(display("length must be 32 bytes, but got {len} byte(s)"))]
92 InvalidLength { len: usize },
93 #[snafu(display("node id is not a valid public key"))]
94 InvalidSignature { source: SignatureError },
95}
96
97pub trait NodeIdExt {
100 fn to_z32(&self) -> String;
104
105 fn from_z32(s: &str) -> Result<NodeId, DecodingError>;
109}
110
111impl NodeIdExt for NodeId {
112 fn to_z32(&self) -> String {
113 z32::encode(self.as_bytes())
114 }
115
116 fn from_z32(s: &str) -> Result<NodeId, DecodingError> {
117 let bytes = z32::decode(s.as_bytes()).context(InvalidEncodingZ32Snafu)?;
118 let bytes: &[u8; 32] = &bytes
119 .try_into()
120 .map_err(|_| InvalidLengthSnafu { len: s.len() }.build())?;
121 let node_id = NodeId::from_bytes(bytes).context(InvalidSignatureSnafu)?;
122 Ok(node_id)
123 }
124}
125
126#[derive(Debug, Clone, Default, Eq, PartialEq)]
135pub struct NodeData {
136 relay_url: Option<RelayUrl>,
138 direct_addresses: BTreeSet<SocketAddr>,
140 user_data: Option<UserData>,
142}
143
144impl NodeData {
145 pub fn new(relay_url: Option<RelayUrl>, direct_addresses: BTreeSet<SocketAddr>) -> Self {
147 Self {
148 relay_url,
149 direct_addresses,
150 user_data: None,
151 }
152 }
153
154 pub fn with_relay_url(mut self, relay_url: Option<RelayUrl>) -> Self {
156 self.relay_url = relay_url;
157 self
158 }
159
160 pub fn with_direct_addresses(mut self, direct_addresses: BTreeSet<SocketAddr>) -> Self {
162 self.direct_addresses = direct_addresses;
163 self
164 }
165
166 pub fn with_user_data(mut self, user_data: Option<UserData>) -> Self {
168 self.user_data = user_data;
169 self
170 }
171
172 pub fn relay_url(&self) -> Option<&RelayUrl> {
174 self.relay_url.as_ref()
175 }
176
177 pub fn user_data(&self) -> Option<&UserData> {
179 self.user_data.as_ref()
180 }
181
182 pub fn direct_addresses(&self) -> &BTreeSet<SocketAddr> {
184 &self.direct_addresses
185 }
186
187 pub fn clear_direct_addresses(&mut self) {
189 self.direct_addresses = Default::default();
190 }
191
192 pub fn add_direct_addresses(&mut self, addrs: impl IntoIterator<Item = SocketAddr>) {
194 self.direct_addresses.extend(addrs)
195 }
196
197 pub fn set_relay_url(&mut self, relay_url: Option<RelayUrl>) {
199 self.relay_url = relay_url
200 }
201
202 pub fn set_user_data(&mut self, user_data: Option<UserData>) {
204 self.user_data = user_data;
205 }
206}
207
208impl From<NodeAddr> for NodeData {
209 fn from(node_addr: NodeAddr) -> Self {
210 Self {
211 relay_url: node_addr.relay_url,
212 direct_addresses: node_addr.direct_addresses,
213 user_data: None,
214 }
215 }
216}
217
218#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
227pub struct UserData(String);
228
229impl UserData {
230 pub const MAX_LENGTH: usize = 245;
236}
237
238#[allow(missing_docs)]
240#[derive(Debug, Snafu)]
241pub struct MaxLengthExceededError {
242 backtrace: Option<Backtrace>,
243 #[snafu(implicit)]
244 span_trace: n0_snafu::SpanTrace,
245}
246
247impl TryFrom<String> for UserData {
248 type Error = MaxLengthExceededError;
249
250 fn try_from(value: String) -> Result<Self, Self::Error> {
251 snafu::ensure!(value.len() <= Self::MAX_LENGTH, MaxLengthExceededSnafu);
252 Ok(Self(value))
253 }
254}
255
256impl FromStr for UserData {
257 type Err = MaxLengthExceededError;
258
259 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
260 snafu::ensure!(s.len() <= Self::MAX_LENGTH, MaxLengthExceededSnafu);
261 Ok(Self(s.to_string()))
262 }
263}
264
265impl fmt::Display for UserData {
266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267 write!(f, "{}", self.0)
268 }
269}
270
271impl AsRef<str> for UserData {
272 fn as_ref(&self) -> &str {
273 &self.0
274 }
275}
276
277#[derive(derive_more::Debug, Clone, Eq, PartialEq)]
281pub struct NodeInfo {
282 pub node_id: NodeId,
284 pub data: NodeData,
286}
287
288impl From<TxtAttrs<IrohAttr>> for NodeInfo {
289 fn from(attrs: TxtAttrs<IrohAttr>) -> Self {
290 (&attrs).into()
291 }
292}
293
294impl From<&TxtAttrs<IrohAttr>> for NodeInfo {
295 fn from(attrs: &TxtAttrs<IrohAttr>) -> Self {
296 let node_id = attrs.node_id();
297 let attrs = attrs.attrs();
298 let relay_url = attrs
299 .get(&IrohAttr::Relay)
300 .into_iter()
301 .flatten()
302 .next()
303 .and_then(|s| Url::parse(s).ok());
304 let direct_addresses = attrs
305 .get(&IrohAttr::Addr)
306 .into_iter()
307 .flatten()
308 .filter_map(|s| SocketAddr::from_str(s).ok())
309 .collect();
310 let user_data = attrs
311 .get(&IrohAttr::UserData)
312 .into_iter()
313 .flatten()
314 .next()
315 .and_then(|s| UserData::from_str(s).ok());
316 let data = NodeData {
317 relay_url: relay_url.map(Into::into),
318 direct_addresses,
319 user_data,
320 };
321 Self { node_id, data }
322 }
323}
324
325impl From<NodeInfo> for NodeAddr {
326 fn from(value: NodeInfo) -> Self {
327 value.into_node_addr()
328 }
329}
330
331impl From<NodeAddr> for NodeInfo {
332 fn from(addr: NodeAddr) -> Self {
333 Self::new(addr.node_id)
334 .with_relay_url(addr.relay_url)
335 .with_direct_addresses(addr.direct_addresses)
336 }
337}
338
339impl NodeInfo {
340 pub fn new(node_id: NodeId) -> Self {
342 Self::from_parts(node_id, Default::default())
343 }
344
345 pub fn from_parts(node_id: NodeId, data: NodeData) -> Self {
347 Self { node_id, data }
348 }
349
350 pub fn with_relay_url(mut self, relay_url: Option<RelayUrl>) -> Self {
352 self.data = self.data.with_relay_url(relay_url);
353 self
354 }
355
356 pub fn with_direct_addresses(mut self, direct_addresses: BTreeSet<SocketAddr>) -> Self {
358 self.data = self.data.with_direct_addresses(direct_addresses);
359 self
360 }
361
362 pub fn with_user_data(mut self, user_data: Option<UserData>) -> Self {
364 self.data = self.data.with_user_data(user_data);
365 self
366 }
367
368 pub fn to_node_addr(&self) -> NodeAddr {
370 NodeAddr {
371 node_id: self.node_id,
372 relay_url: self.data.relay_url.clone(),
373 direct_addresses: self.data.direct_addresses.clone(),
374 }
375 }
376
377 pub fn into_node_addr(self) -> NodeAddr {
379 NodeAddr {
380 node_id: self.node_id,
381 relay_url: self.data.relay_url,
382 direct_addresses: self.data.direct_addresses,
383 }
384 }
385
386 fn to_attrs(&self) -> TxtAttrs<IrohAttr> {
387 self.into()
388 }
389
390 #[cfg(not(wasm_browser))]
391 pub fn from_txt_lookup(lookup: crate::dns::TxtLookup) -> Result<Self, ParseError> {
393 let attrs = TxtAttrs::from_txt_lookup(lookup)?;
394 Ok(attrs.into())
395 }
396
397 pub fn from_pkarr_signed_packet(packet: &pkarr::SignedPacket) -> Result<Self, ParseError> {
399 let attrs = TxtAttrs::from_pkarr_signed_packet(packet)?;
400 Ok(attrs.into())
401 }
402
403 pub fn to_pkarr_signed_packet(
407 &self,
408 secret_key: &SecretKey,
409 ttl: u32,
410 ) -> Result<pkarr::SignedPacket, EncodingError> {
411 self.to_attrs().to_pkarr_signed_packet(secret_key, ttl)
412 }
413
414 pub fn to_txt_strings(&self) -> Vec<String> {
416 self.to_attrs().to_txt_strings().collect()
417 }
418}
419
420#[cfg(not(wasm_browser))]
421#[common_fields({
422 backtrace: Option<Backtrace>,
423 #[snafu(implicit)]
424 span_trace: n0_snafu::SpanTrace,
425})]
426#[allow(missing_docs)]
427#[derive(Debug, Snafu)]
428#[non_exhaustive]
429#[snafu(visibility(pub(crate)))]
430pub enum LookupError {
431 #[snafu(display("Malformed txt from lookup"))]
432 ParseError {
433 #[snafu(source(from(ParseError, Box::new)))]
434 source: Box<ParseError>,
435 },
436 #[snafu(display("Failed to resolve TXT record"))]
437 LookupFailed {
438 #[snafu(source(from(DnsError, Box::new)))]
439 source: Box<DnsError>,
440 },
441 #[cfg(not(wasm_browser))]
442 #[snafu(display("Name is not a valid TXT label"))]
443 InvalidLabel { source: ProtoError },
444}
445
446#[common_fields({
447 backtrace: Option<Backtrace>,
448 #[snafu(implicit)]
449 span_trace: n0_snafu::SpanTrace,
450})]
451#[allow(missing_docs)]
452#[derive(Debug, Snafu)]
453#[non_exhaustive]
454#[snafu(visibility(pub(crate)))]
455pub enum ParseError {
456 #[snafu(display("Expected format `key=value`, received `{s}`"))]
457 UnexpectedFormat { s: String },
458 #[snafu(display("Could not convert key to Attr"))]
459 AttrFromString { key: String },
460 #[snafu(display("Expected 2 labels, received {num_labels}"))]
461 NumLabels { num_labels: usize },
462 #[snafu(display("Could not parse labels"))]
463 Utf8 { source: Utf8Error },
464 #[snafu(display("Record is not an `iroh` record, expected `_iroh`, got `{label}`"))]
465 NotAnIrohRecord { label: String },
466 #[snafu(transparent)]
467 DecodingError {
468 #[snafu(source(from(DecodingError, Box::new)))]
469 source: Box<DecodingError>,
470 },
471}
472
473impl std::ops::Deref for NodeInfo {
474 type Target = NodeData;
475 fn deref(&self) -> &Self::Target {
476 &self.data
477 }
478}
479
480impl std::ops::DerefMut for NodeInfo {
481 fn deref_mut(&mut self) -> &mut Self::Target {
482 &mut self.data
483 }
484}
485
486#[cfg(not(wasm_browser))]
492fn node_id_from_hickory_name(
493 name: &hickory_resolver::proto::rr::Name,
494) -> Result<NodeId, ParseError> {
495 if name.num_labels() < 2 {
496 return Err(NumLabelsSnafu {
497 num_labels: name.num_labels(),
498 }
499 .build());
500 }
501 let mut labels = name.iter();
502 let label =
503 std::str::from_utf8(labels.next().expect("num_labels checked")).context(Utf8Snafu)?;
504 if label != IROH_TXT_NAME {
505 return Err(NotAnIrohRecordSnafu { label }.build());
506 }
507 let label =
508 std::str::from_utf8(labels.next().expect("num_labels checked")).context(Utf8Snafu)?;
509 let node_id = NodeId::from_z32(label)?;
510 Ok(node_id)
511}
512
513#[derive(
517 Debug, strum::Display, strum::AsRefStr, strum::EnumString, Hash, Eq, PartialEq, Ord, PartialOrd,
518)]
519#[strum(serialize_all = "kebab-case")]
520pub(crate) enum IrohAttr {
521 Relay,
523 Addr,
525 UserData,
527}
528
529#[derive(Debug)]
535pub(crate) struct TxtAttrs<T> {
536 node_id: NodeId,
537 attrs: BTreeMap<T, Vec<String>>,
538}
539
540impl From<&NodeInfo> for TxtAttrs<IrohAttr> {
541 fn from(info: &NodeInfo) -> Self {
542 let mut attrs = vec![];
543 if let Some(relay_url) = &info.data.relay_url {
544 attrs.push((IrohAttr::Relay, relay_url.to_string()));
545 }
546 for addr in &info.data.direct_addresses {
547 attrs.push((IrohAttr::Addr, addr.to_string()));
548 }
549 if let Some(user_data) = &info.data.user_data {
550 attrs.push((IrohAttr::UserData, user_data.to_string()));
551 }
552 Self::from_parts(info.node_id, attrs.into_iter())
553 }
554}
555
556impl<T: FromStr + Display + Hash + Ord> TxtAttrs<T> {
557 pub(crate) fn from_parts(node_id: NodeId, pairs: impl Iterator<Item = (T, String)>) -> Self {
559 let mut attrs: BTreeMap<T, Vec<String>> = BTreeMap::new();
560 for (k, v) in pairs {
561 attrs.entry(k).or_default().push(v);
562 }
563 Self { attrs, node_id }
564 }
565
566 pub(crate) fn from_strings(
568 node_id: NodeId,
569 strings: impl Iterator<Item = String>,
570 ) -> Result<Self, ParseError> {
571 let mut attrs: BTreeMap<T, Vec<String>> = BTreeMap::new();
572 for s in strings {
573 let mut parts = s.split('=');
574 let (Some(key), Some(value)) = (parts.next(), parts.next()) else {
575 return Err(UnexpectedFormatSnafu { s }.build());
576 };
577 let attr = T::from_str(key).map_err(|_| AttrFromStringSnafu { key }.build())?;
578 attrs.entry(attr).or_default().push(value.to_string());
579 }
580 Ok(Self { attrs, node_id })
581 }
582
583 #[cfg(not(wasm_browser))]
584 async fn lookup(resolver: &DnsResolver, name: Name) -> Result<Self, LookupError> {
585 let name = ensure_iroh_txt_label(name)?;
586 let lookup = resolver
587 .lookup_txt(name, DNS_TIMEOUT)
588 .await
589 .context(LookupFailedSnafu)?;
590 let attrs = Self::from_txt_lookup(lookup).context(ParseSnafu)?;
591 Ok(attrs)
592 }
593
594 #[cfg(not(wasm_browser))]
596 pub(crate) async fn lookup_by_id(
597 resolver: &DnsResolver,
598 node_id: &NodeId,
599 origin: &str,
600 ) -> Result<Self, LookupError> {
601 let name = node_domain(node_id, origin)?;
602 TxtAttrs::lookup(resolver, name).await
603 }
604
605 #[cfg(not(wasm_browser))]
607 pub(crate) async fn lookup_by_name(
608 resolver: &DnsResolver,
609 name: &str,
610 ) -> Result<Self, LookupError> {
611 let name = Name::from_str(name).context(InvalidLabelSnafu)?;
612 TxtAttrs::lookup(resolver, name).await
613 }
614
615 pub(crate) fn attrs(&self) -> &BTreeMap<T, Vec<String>> {
617 &self.attrs
618 }
619
620 pub(crate) fn node_id(&self) -> NodeId {
622 self.node_id
623 }
624
625 pub(crate) fn from_pkarr_signed_packet(
627 packet: &pkarr::SignedPacket,
628 ) -> Result<Self, ParseError> {
629 use pkarr::dns::{
630 rdata::RData,
631 {self},
632 };
633 let pubkey = packet.public_key();
634 let pubkey_z32 = pubkey.to_z32();
635 let node_id = NodeId::from(*pubkey.verifying_key());
636 let zone = dns::Name::new(&pubkey_z32).expect("z32 encoding is valid");
637 let txt_data = packet
638 .all_resource_records()
639 .filter_map(|rr| match &rr.rdata {
640 RData::TXT(txt) => match rr.name.without(&zone) {
641 Some(name) if name.to_string() == IROH_TXT_NAME => Some(txt),
642 Some(_) | None => None,
643 },
644 _ => None,
645 });
646
647 let txt_strs = txt_data.filter_map(|s| String::try_from(s.clone()).ok());
648 Self::from_strings(node_id, txt_strs)
649 }
650
651 #[cfg(not(wasm_browser))]
653 pub(crate) fn from_txt_lookup(lookup: crate::dns::TxtLookup) -> Result<Self, ParseError> {
654 let queried_node_id = node_id_from_hickory_name(lookup.0.query().name())?;
655
656 let strings = lookup.0.as_lookup().record_iter().filter_map(|record| {
657 match node_id_from_hickory_name(record.name()) {
658 Ok(n) if n == queried_node_id => match record.data().as_txt() {
660 Some(txt) => Some(txt.to_string()),
661 None => {
662 warn!(
663 ?queried_node_id,
664 data = ?record.data(),
665 "unexpected record type for DNS discovery query"
666 );
667 None
668 }
669 },
670 Ok(answered_node_id) => {
671 warn!(
672 ?queried_node_id,
673 ?answered_node_id,
674 "unexpected node ID answered for DNS query"
675 );
676 None
677 }
678 Err(e) => {
679 warn!(
680 ?queried_node_id,
681 name = ?record.name(),
682 err = ?e,
683 "unexpected answer record name for DNS query"
684 );
685 None
686 }
687 }
688 });
689
690 Self::from_strings(queried_node_id, strings)
691 }
692
693 fn to_txt_strings(&self) -> impl Iterator<Item = String> + '_ {
694 self.attrs
695 .iter()
696 .flat_map(move |(k, vs)| vs.iter().map(move |v| format!("{k}={v}")))
697 }
698
699 pub(crate) fn to_pkarr_signed_packet(
703 &self,
704 secret_key: &SecretKey,
705 ttl: u32,
706 ) -> Result<pkarr::SignedPacket, EncodingError> {
707 use pkarr::dns::{self, rdata};
708 let keypair = pkarr::Keypair::from_secret_key(&secret_key.to_bytes());
709 let name = dns::Name::new(IROH_TXT_NAME).expect("constant");
710
711 let mut builder = pkarr::SignedPacket::builder();
712 for s in self.to_txt_strings() {
713 let mut txt = rdata::TXT::new();
714 txt.add_string(&s).context(InvalidTxtEntrySnafu)?;
715 builder = builder.txt(name.clone(), txt.into_owned(), ttl);
716 }
717 let signed_packet = builder.build(&keypair)?;
718 Ok(signed_packet)
719 }
720}
721
722#[cfg(not(wasm_browser))]
723fn ensure_iroh_txt_label(name: Name) -> Result<Name, LookupError> {
724 if name.iter().next() == Some(IROH_TXT_NAME.as_bytes()) {
725 Ok(name)
726 } else {
727 Name::parse(IROH_TXT_NAME, Some(&name)).context(InvalidLabelSnafu)
728 }
729}
730
731#[cfg(not(wasm_browser))]
732fn node_domain(node_id: &NodeId, origin: &str) -> Result<Name, LookupError> {
733 let domain = format!("{}.{origin}", NodeId::to_z32(node_id));
734 let domain = Name::from_str(&domain).context(InvalidLabelSnafu)?;
735 Ok(domain)
736}
737
738#[cfg(test)]
739mod tests {
740 use std::{collections::BTreeSet, str::FromStr, sync::Arc};
741
742 use hickory_resolver::{
743 Name,
744 lookup::Lookup,
745 proto::{
746 op::Query,
747 rr::{
748 RData, Record, RecordType,
749 rdata::{A, TXT},
750 },
751 },
752 };
753 use iroh_base::{NodeId, SecretKey};
754 use n0_snafu::{Result, ResultExt};
755
756 use super::{NodeData, NodeIdExt, NodeInfo};
757
758 #[test]
759 fn txt_attr_roundtrip() {
760 let node_data = NodeData::new(
761 Some("https://example.com".parse().unwrap()),
762 ["127.0.0.1:1234".parse().unwrap()].into_iter().collect(),
763 )
764 .with_user_data(Some("foobar".parse().unwrap()));
765 let node_id = "vpnk377obfvzlipnsfbqba7ywkkenc4xlpmovt5tsfujoa75zqia"
766 .parse()
767 .unwrap();
768 let expected = NodeInfo::from_parts(node_id, node_data);
769 let attrs = expected.to_attrs();
770 let actual = NodeInfo::from(&attrs);
771 assert_eq!(expected, actual);
772 }
773
774 #[test]
775 fn signed_packet_roundtrip() {
776 let secret_key =
777 SecretKey::from_str("vpnk377obfvzlipnsfbqba7ywkkenc4xlpmovt5tsfujoa75zqia").unwrap();
778 let node_data = NodeData::new(
779 Some("https://example.com".parse().unwrap()),
780 ["127.0.0.1:1234".parse().unwrap()].into_iter().collect(),
781 )
782 .with_user_data(Some("foobar".parse().unwrap()));
783 let expected = NodeInfo::from_parts(secret_key.public(), node_data);
784 let packet = expected.to_pkarr_signed_packet(&secret_key, 30).unwrap();
785 let actual = NodeInfo::from_pkarr_signed_packet(&packet).unwrap();
786 assert_eq!(expected, actual);
787 }
788
789 #[test]
796 fn test_from_hickory_lookup() -> Result {
797 let name = Name::from_utf8(
798 "_iroh.dgjpkxyn3zyrk3zfads5duwdgbqpkwbjxfj4yt7rezidr3fijccy.dns.iroh.link.",
799 )
800 .context("dns name")?;
801 let query = Query::query(name.clone(), RecordType::TXT);
802 let records = [
803 Record::from_rdata(
804 name.clone(),
805 30,
806 RData::TXT(TXT::new(vec!["addr=192.168.96.145:60165".to_string()])),
807 ),
808 Record::from_rdata(
809 name.clone(),
810 30,
811 RData::TXT(TXT::new(vec!["addr=213.208.157.87:60165".to_string()])),
812 ),
813 Record::from_rdata(name.clone(), 30, RData::A(A::new(127, 0, 0, 1))),
815 Record::from_rdata(
817 Name::from_utf8(format!(
818 "_iroh.{}.dns.iroh.link.",
819 NodeId::from_str(
820 "a55f26132e5e43de834d534332f66a20d480c3e50a13a312a071adea6569981e"
822 )?
823 .to_z32()
824 ))
825 .context("name")?,
826 30,
827 RData::TXT(TXT::new(vec![
828 "relay=https://euw1-1.relay.iroh.network./".to_string(),
829 ])),
830 ),
831 Record::from_rdata(
833 Name::from_utf8("dns.iroh.link.").context("name")?,
834 30,
835 RData::TXT(TXT::new(vec![
836 "relay=https://euw1-1.relay.iroh.network./".to_string(),
837 ])),
838 ),
839 Record::from_rdata(
840 name.clone(),
841 30,
842 RData::TXT(TXT::new(vec![
843 "relay=https://euw1-1.relay.iroh.network./".to_string(),
844 ])),
845 ),
846 ];
847 let lookup = Lookup::new_with_max_ttl(query, Arc::new(records));
848 let lookup = hickory_resolver::lookup::TxtLookup::from(lookup);
849
850 let node_info = NodeInfo::from_txt_lookup(lookup.into())?;
851
852 let expected_node_info = NodeInfo::new(NodeId::from_str(
853 "1992d53c02cdc04566e5c0edb1ce83305cd550297953a047a445ea3264b54b18",
854 )?)
855 .with_relay_url(Some("https://euw1-1.relay.iroh.network./".parse()?))
856 .with_direct_addresses(BTreeSet::from([
857 "192.168.96.145:60165".parse().unwrap(),
858 "213.208.157.87:60165".parse().unwrap(),
859 ]));
860
861 assert_eq!(node_info, expected_node_info);
862
863 Ok(())
864 }
865}