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, 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
58/// The DNS name for the iroh TXT record.
59pub 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
97/// Extension methods for [`NodeId`] to encode to and decode from [`z32`],
98/// which is the encoding used in [`pkarr`] domain names.
99pub trait NodeIdExt {
100    /// Encodes a [`NodeId`] in [`z-base-32`] encoding.
101    ///
102    /// [z-base-32]: https://philzimmermann.com/docs/human-oriented-base-32-encoding.txt
103    fn to_z32(&self) -> String;
104
105    /// Parses a [`NodeId`] from [`z-base-32`] encoding.
106    ///
107    /// [z-base-32]: https://philzimmermann.com/docs/human-oriented-base-32-encoding.txt
108    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/// Data about a node that may be published to and resolved from discovery services.
127///
128/// This includes an optional [`RelayUrl`], a set of direct addresses, and the optional
129/// [`UserData`], a string that can be set by applications and is not parsed or used by iroh
130/// itself.
131///
132/// This struct does not include the node's [`NodeId`], only the data *about* a certain
133/// node. See [`NodeInfo`] for a struct that contains a [`NodeId`] with associated [`NodeData`].
134#[derive(Debug, Clone, Default, Eq, PartialEq)]
135pub struct NodeData {
136    /// URL of the home relay of this node.
137    relay_url: Option<RelayUrl>,
138    /// Direct addresses where this node can be reached.
139    direct_addresses: BTreeSet<SocketAddr>,
140    /// Optional user-defined [`UserData`] for this node.
141    user_data: Option<UserData>,
142}
143
144impl NodeData {
145    /// Creates a new [`NodeData`] with a relay URL and a set of direct addresses.
146    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    /// Sets the relay URL and returns the updated node data.
155    pub fn with_relay_url(mut self, relay_url: Option<RelayUrl>) -> Self {
156        self.relay_url = relay_url;
157        self
158    }
159
160    /// Sets the direct addresses and returns the updated node data.
161    pub fn with_direct_addresses(mut self, direct_addresses: BTreeSet<SocketAddr>) -> Self {
162        self.direct_addresses = direct_addresses;
163        self
164    }
165
166    /// Sets the user-defined data and returns the updated node data.
167    pub fn with_user_data(mut self, user_data: Option<UserData>) -> Self {
168        self.user_data = user_data;
169        self
170    }
171
172    /// Returns the relay URL of the node.
173    pub fn relay_url(&self) -> Option<&RelayUrl> {
174        self.relay_url.as_ref()
175    }
176
177    /// Returns the optional user-defined data of the node.
178    pub fn user_data(&self) -> Option<&UserData> {
179        self.user_data.as_ref()
180    }
181
182    /// Returns the direct addresses of the node.
183    pub fn direct_addresses(&self) -> &BTreeSet<SocketAddr> {
184        &self.direct_addresses
185    }
186
187    /// Removes all direct addresses from the node data.
188    pub fn clear_direct_addresses(&mut self) {
189        self.direct_addresses = Default::default();
190    }
191
192    /// Adds direct addresses to the node data.
193    pub fn add_direct_addresses(&mut self, addrs: impl IntoIterator<Item = SocketAddr>) {
194        self.direct_addresses.extend(addrs)
195    }
196
197    /// Sets the relay URL of the node data.
198    pub fn set_relay_url(&mut self, relay_url: Option<RelayUrl>) {
199        self.relay_url = relay_url
200    }
201
202    /// Sets the user-defined data of the node data.
203    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// User-defined data that can be published and resolved through node discovery.
219///
220/// Under the hood this is a UTF-8 String is no longer than [`UserData::MAX_LENGTH`] bytes.
221///
222/// Iroh does not keep track of or examine the user-defined data.
223///
224/// `UserData` implements [`FromStr`] and [`TryFrom<String>`], so you can
225/// convert `&str` and `String` into `UserData` easily.
226#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
227pub struct UserData(String);
228
229impl UserData {
230    /// The max byte length allowed for user-defined data.
231    ///
232    /// In DNS discovery services, the user-defined data is stored in a TXT record character string,
233    /// which has a max length of 255 bytes. We need to subtract the `user-data=` prefix,
234    /// which leaves 245 bytes for the actual user-defined data.
235    pub const MAX_LENGTH: usize = 245;
236}
237
238/// Error returned when an input value is too long for [`UserData`].
239#[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/// Information about a node that may be published to and resolved from discovery services.
278///
279/// This struct couples a [`NodeId`] with its associated [`NodeData`].
280#[derive(derive_more::Debug, Clone, Eq, PartialEq)]
281pub struct NodeInfo {
282    /// The [`NodeId`] of the node this is about.
283    pub node_id: NodeId,
284    /// The information published about the node.
285    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    /// Creates a new [`NodeInfo`] with an empty [`NodeData`].
341    pub fn new(node_id: NodeId) -> Self {
342        Self::from_parts(node_id, Default::default())
343    }
344
345    /// Creates a new [`NodeInfo`] from its parts.
346    pub fn from_parts(node_id: NodeId, data: NodeData) -> Self {
347        Self { node_id, data }
348    }
349
350    /// Sets the relay URL and returns the updated node info.
351    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    /// Sets the direct addresses and returns the updated node info.
357    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    /// Sets the user-defined data and returns the updated node info.
363    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    /// Converts into a [`NodeAddr`] by cloning the needed fields.
369    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    /// Converts into a [`NodeAddr`] without cloning.
378    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    /// Parses a [`NodeInfo`] from a TXT records lookup.
392    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    /// Parses a [`NodeInfo`] from a [`pkarr::SignedPacket`].
398    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    /// Creates a [`pkarr::SignedPacket`].
404    ///
405    /// This constructs a DNS packet and signs it with a [`SecretKey`].
406    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    /// Converts into a list of `{key}={value}` strings.
415    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/// Parses a [`NodeId`] from iroh DNS name.
487///
488/// Takes a [`hickory_resolver::proto::rr::Name`] DNS name and expects the first label to be
489/// [`IROH_TXT_NAME`] and the second label to be a z32 encoded [`NodeId`]. Ignores
490/// subsequent labels.
491#[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/// The attributes supported by iroh for [`IROH_TXT_NAME`] DNS resource records.
514///
515/// The resource record uses the lower-case names.
516#[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    /// URL of home relay.
522    Relay,
523    /// Direct address.
524    Addr,
525    /// User-defined data
526    UserData,
527}
528
529/// Attributes parsed from [`IROH_TXT_NAME`] TXT records.
530///
531/// This struct is generic over the key type. When using with [`String`], this will parse
532/// all attributes. Can also be used with an enum, if it implements [`FromStr`] and
533/// [`Display`].
534#[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    /// Creates [`TxtAttrs`] from a node id and an iterator of key-value pairs.
558    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    /// Creates [`TxtAttrs`] from a node id and an iterator of "{key}={value}" strings.
567    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    /// Looks up attributes by [`NodeId`] and origin domain.
595    #[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    /// Looks up attributes by DNS name.
606    #[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    /// Returns the parsed attributes.
616    pub(crate) fn attrs(&self) -> &BTreeMap<T, Vec<String>> {
617        &self.attrs
618    }
619
620    /// Returns the node id.
621    pub(crate) fn node_id(&self) -> NodeId {
622        self.node_id
623    }
624
625    /// Parses a [`pkarr::SignedPacket`].
626    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    /// Parses a TXT records lookup.
652    #[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                // Filter out only TXT record answers that match the node_id we searched for.
659                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    /// Creates a [`pkarr::SignedPacket`]
700    ///
701    /// This constructs a DNS packet and signs it with a [`SecretKey`].
702    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    /// There used to be a bug where uploading a NodeAddr with more than only exactly
790    /// one relay URL or one publicly reachable IP addr would prevent connection
791    /// establishment.
792    ///
793    /// The reason was that only the first address was parsed (e.g. 192.168.96.145 in
794    /// this example), which could be a local, unreachable address.
795    #[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            // Test a record with mismatching record type (A instead of TXT). It should be filtered out.
814            Record::from_rdata(name.clone(), 30, RData::A(A::new(127, 0, 0, 1))),
815            // Test a record with a mismatching name
816            Record::from_rdata(
817                Name::from_utf8(format!(
818                    "_iroh.{}.dns.iroh.link.",
819                    NodeId::from_str(
820                        // Another NodeId
821                        "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            // Test a record with a completely different name
832            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}