iroh_relay/
node_info.rs

1//! Support for handling DNS resource records for dialing by [`NodeId`].
2//!
3//! Dialing by [`NodeId`] is supported by iroh nodes publishing [Pkarr] records to DNS
4//! servers or the Mainline DHT.  This module supports creating and parsing these records.
5//!
6//! DNS records are published under the following names:
7//!
8//! `_iroh.<z32-node-id>.<origin-domain> TXT`
9//!
10//! - `_iroh` is the record name as defined by [`IROH_TXT_NAME`].
11//!
12//! - `<z32-node-id>` is the [z-base-32] encoding of the [`NodeId`].
13//!
14//! - `<origin-domain>` is the domain name of the publishing DNS server,
15//!   [`N0_DNS_NODE_ORIGIN_PROD`] is the server operated by number0 for production.
16//!   [`N0_DNS_NODE_ORIGIN_STAGING`] is the server operated by number0 for testing.
17//!
18//! - `TXT` is the DNS record type.
19//!
20//! The returned TXT records must contain a string value of the form `key=value` as defined
21//! in [RFC1464].  The following attributes are defined:
22//!
23//! - `relay=<url>`: The home [`RelayUrl`] of this node.
24//!
25//! - `addr=<addr> <addr>`: A space-separated list of sockets addresses for this iroh node.
26//!   Each address is an IPv4 or IPv6 address with a port.
27//!
28//! [Pkarr]: https://app.pkarr.org
29//! [z-base-32]: https://philzimmermann.com/docs/human-oriented-base-32-encoding.txt
30//! [RFC1464]: https://www.rfc-editor.org/rfc/rfc1464
31//! [`RelayUrl`]: iroh_base::RelayUrl
32//! [`N0_DNS_NODE_ORIGIN_PROD`]: crate::dns::N0_DNS_NODE_ORIGIN_PROD
33//! [`N0_DNS_NODE_ORIGIN_STAGING`]: crate::dns::N0_DNS_NODE_ORIGIN_STAGING
34
35use 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
54/// The DNS name for the iroh TXT record.
55pub const IROH_TXT_NAME: &str = "_iroh";
56
57/// Extension methods for [`NodeId`] to encode to and decode from [`z32`],
58/// which is the encoding used in [`pkarr`] domain names.
59pub trait NodeIdExt {
60    /// Encodes a [`NodeId`] in [`z-base-32`] encoding.
61    ///
62    /// [z-base-32]: https://philzimmermann.com/docs/human-oriented-base-32-encoding.txt
63    fn to_z32(&self) -> String;
64
65    /// Parses a [`NodeId`] from [`z-base-32`] encoding.
66    ///
67    /// [z-base-32]: https://philzimmermann.com/docs/human-oriented-base-32-encoding.txt
68    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/// Data about a node that may be published to and resolved from discovery services.
85///
86/// This includes an optional [`RelayUrl`], a set of direct addresses, and the optional
87/// [`UserData`], a string that can be set by applications and is not parsed or used by iroh
88/// itself.
89///
90/// This struct does not include the node's [`NodeId`], only the data *about* a certain
91/// node. See [`NodeInfo`] for a struct that contains a [`NodeId`] with associated [`NodeData`].
92#[derive(Debug, Clone, Default, Eq, PartialEq)]
93pub struct NodeData {
94    /// URL of the home relay of this node.
95    relay_url: Option<RelayUrl>,
96    /// Direct addresses where this node can be reached.
97    direct_addresses: BTreeSet<SocketAddr>,
98    /// Optional user-defined [`UserData`] for this node.
99    user_data: Option<UserData>,
100}
101
102impl NodeData {
103    /// Creates a new [`NodeData`] with a relay URL and a set of direct addresses.
104    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    /// Sets the relay URL and returns the updated node data.
113    pub fn with_relay_url(mut self, relay_url: Option<RelayUrl>) -> Self {
114        self.relay_url = relay_url;
115        self
116    }
117
118    /// Sets the direct addresses and returns the updated node data.
119    pub fn with_direct_addresses(mut self, direct_addresses: BTreeSet<SocketAddr>) -> Self {
120        self.direct_addresses = direct_addresses;
121        self
122    }
123
124    /// Sets the user-defined data and returns the updated node data.
125    pub fn with_user_data(mut self, user_data: Option<UserData>) -> Self {
126        self.user_data = user_data;
127        self
128    }
129
130    /// Returns the relay URL of the node.
131    pub fn relay_url(&self) -> Option<&RelayUrl> {
132        self.relay_url.as_ref()
133    }
134
135    /// Returns the optional user-defined data of the node.
136    pub fn user_data(&self) -> Option<&UserData> {
137        self.user_data.as_ref()
138    }
139
140    /// Returns the direct addresses of the node.
141    pub fn direct_addresses(&self) -> &BTreeSet<SocketAddr> {
142        &self.direct_addresses
143    }
144
145    /// Removes all direct addresses from the node data.
146    pub fn clear_direct_addresses(&mut self) {
147        self.direct_addresses = Default::default();
148    }
149
150    /// Adds direct addresses to the node data.
151    pub fn add_direct_addresses(&mut self, addrs: impl IntoIterator<Item = SocketAddr>) {
152        self.direct_addresses.extend(addrs)
153    }
154
155    /// Sets the relay URL of the node data.
156    pub fn set_relay_url(&mut self, relay_url: Option<RelayUrl>) {
157        self.relay_url = relay_url
158    }
159
160    /// Sets the user-defined data of the node data.
161    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// User-defined data that can be published and resolved through node discovery.
177///
178/// Under the hood this is a UTF-8 String is no longer than [`UserData::MAX_LENGTH`] bytes.
179///
180/// Iroh does not keep track of or examine the user-defined data.
181///
182/// `UserData` implements [`FromStr`] and [`TryFrom<String>`], so you can
183/// convert `&str` and `String` into `UserData` easily.
184#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
185pub struct UserData(String);
186
187impl UserData {
188    /// The max byte length allowed for user-defined data.
189    ///
190    /// In DNS discovery services, the user-defined data is stored in a TXT record character string,
191    /// which has a max length of 255 bytes. We need to subtract the `user-data=` prefix,
192    /// which leaves 245 bytes for the actual user-defined data.
193    pub const MAX_LENGTH: usize = 245;
194}
195
196/// Error returned when an input value is too long for [`UserData`].
197#[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/// Information about a node that may be published to and resolved from discovery services.
238///
239/// This struct couples a [`NodeId`] with its associated [`NodeData`].
240#[derive(derive_more::Debug, Clone, Eq, PartialEq)]
241pub struct NodeInfo {
242    /// The [`NodeId`] of the node this is about.
243    pub node_id: NodeId,
244    /// The information published about the node.
245    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    /// Creates a new [`NodeInfo`] with an empty [`NodeData`].
301    pub fn new(node_id: NodeId) -> Self {
302        Self::from_parts(node_id, Default::default())
303    }
304
305    /// Creates a new [`NodeInfo`] from its parts.
306    pub fn from_parts(node_id: NodeId, data: NodeData) -> Self {
307        Self { node_id, data }
308    }
309
310    /// Sets the relay URL and returns the updated node info.
311    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    /// Sets the direct addresses and returns the updated node info.
317    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    /// Sets the user-defined data and returns the updated node info.
323    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    /// Converts into a [`NodeAddr`] by cloning the needed fields.
329    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    /// Converts into a [`NodeAddr`] without cloning.
338    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    /// Parses a [`NodeInfo`] from a TXT records lookup.
352    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    /// Parses a [`NodeInfo`] from a [`pkarr::SignedPacket`].
358    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    /// Creates a [`pkarr::SignedPacket`].
364    ///
365    /// This constructs a DNS packet and signs it with a [`SecretKey`].
366    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    /// Converts into a list of `{key}={value}` strings.
375    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/// Parses a [`NodeId`] from iroh DNS name.
394///
395/// Takes a [`hickory_resolver::proto::rr::Name`] DNS name and expects the first label to be
396/// [`IROH_TXT_NAME`] and the second label to be a z32 encoded [`NodeId`]. Ignores
397/// subsequent labels.
398#[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/// The attributes supported by iroh for [`IROH_TXT_NAME`] DNS resource records.
414///
415/// The resource record uses the lower-case names.
416#[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    /// URL of home relay.
422    Relay,
423    /// Direct address.
424    Addr,
425    /// User-defined data
426    UserData,
427}
428
429/// Attributes parsed from [`IROH_TXT_NAME`] TXT records.
430///
431/// This struct is generic over the key type. When using with [`String`], this will parse
432/// all attributes. Can also be used with an enum, if it implements [`FromStr`] and
433/// [`Display`].
434#[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    /// Creates [`TxtAttrs`] from a node id and an iterator of key-value pairs.
458    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    /// Creates [`TxtAttrs`] from a node id and an iterator of "{key}={value}" strings.
467    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    /// Looks up attributes by [`NodeId`] and origin domain.
494    #[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    /// Looks up attributes by DNS name.
505    #[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    /// Returns the parsed attributes.
512    pub(crate) fn attrs(&self) -> &BTreeMap<T, Vec<String>> {
513        &self.attrs
514    }
515
516    /// Returns the node id.
517    pub(crate) fn node_id(&self) -> NodeId {
518        self.node_id
519    }
520
521    /// Parses a [`pkarr::SignedPacket`].
522    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    /// Parses a TXT records lookup.
546    #[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                // Filter out only TXT record answers that match the node_id we searched for.
554                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    /// Creates a [`pkarr::SignedPacket`]
594    ///
595    /// This constructs a DNS packet and signs it with a [`SecretKey`].
596    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    /// There used to be a bug where uploading a NodeAddr with more than only exactly
684    /// one relay URL or one publicly reachable IP addr would prevent connection
685    /// establishment.
686    ///
687    /// The reason was that only the first address was parsed (e.g. 192.168.96.145 in
688    /// this example), which could be a local, unreachable address.
689    #[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            // Test a record with mismatching record type (A instead of TXT). It should be filtered out.
707            Record::from_rdata(name.clone(), 30, RData::A(A::new(127, 0, 0, 1))),
708            // Test a record with a mismatching name
709            Record::from_rdata(
710                Name::from_utf8(format!(
711                    "_iroh.{}.dns.iroh.link.",
712                    NodeId::from_str(
713                        // Another NodeId
714                        "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            // Test a record with a completely different name
724            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}