iroh_relay/
endpoint_info.rs

1//! Support for handling DNS resource records for dialing by [`EndpointId`].
2//!
3//! Dialing by [`EndpointId`] is supported by iroh endpoints 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-endpoint-id>.<origin-domain> TXT`
9//!
10//! - `_iroh` is the record name as defined by [`IROH_TXT_NAME`].
11//!
12//! - `<z32-endpoint-id>` is the [z-base-32] encoding of the [`EndpointId`].
13//!
14//! - `<origin-domain>` is the domain name of the publishing DNS server,
15//!   [`N0_DNS_ENDPOINT_ORIGIN_PROD`] is the server operated by number0 for production.
16//!   [`N0_DNS_ENDPOINT_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 endpoint.
24//!
25//! - `addr=<addr> <addr>`: A space-separated list of sockets addresses for this iroh endpoint.
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_ENDPOINT_ORIGIN_PROD`]: crate::dns::N0_DNS_ENDPOINT_ORIGIN_PROD
33//! [`N0_DNS_ENDPOINT_ORIGIN_STAGING`]: crate::dns::N0_DNS_ENDPOINT_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
43use iroh_base::{EndpointAddr, EndpointId, KeyParsingError, RelayUrl, SecretKey, TransportAddr};
44use n0_error::{e, ensure, stack_error};
45use url::Url;
46
47/// The DNS name for the iroh TXT record.
48pub const IROH_TXT_NAME: &str = "_iroh";
49
50#[allow(missing_docs)]
51#[stack_error(derive, add_meta)]
52#[non_exhaustive]
53pub enum EncodingError {
54    #[error(transparent)]
55    FailedBuildingPacket {
56        #[error(std_err)]
57        source: pkarr::errors::SignedPacketBuildError,
58    },
59    #[error("invalid TXT entry")]
60    InvalidTxtEntry {
61        #[error(std_err)]
62        source: pkarr::dns::SimpleDnsError,
63    },
64}
65
66#[allow(missing_docs)]
67#[stack_error(derive, add_meta)]
68#[non_exhaustive]
69pub enum DecodingError {
70    #[error("endpoint id was not encoded in valid z32")]
71    InvalidEncodingZ32 {
72        #[error(std_err)]
73        source: z32::Z32Error,
74    },
75    #[error("length must be 32 bytes, but got {len} byte(s)")]
76    InvalidLength { len: usize },
77    #[error("endpoint id is not a valid public key")]
78    InvalidKey { source: KeyParsingError },
79}
80
81/// Extension methods for [`EndpointId`] to encode to and decode from [`z32`],
82/// which is the encoding used in [`pkarr`] domain names.
83pub trait EndpointIdExt {
84    /// Encodes a [`EndpointId`] in [`z-base-32`] encoding.
85    ///
86    /// [`z-base-32`]: https://philzimmermann.com/docs/human-oriented-base-32-encoding.txt
87    fn to_z32(&self) -> String;
88
89    /// Parses a [`EndpointId`] from [`z-base-32`] encoding.
90    ///
91    /// [`z-base-32`]: https://philzimmermann.com/docs/human-oriented-base-32-encoding.txt
92    fn from_z32(s: &str) -> Result<EndpointId, DecodingError>;
93}
94
95impl EndpointIdExt for EndpointId {
96    fn to_z32(&self) -> String {
97        z32::encode(self.as_bytes())
98    }
99
100    fn from_z32(s: &str) -> Result<EndpointId, DecodingError> {
101        let bytes =
102            z32::decode(s.as_bytes()).map_err(|err| e!(DecodingError::InvalidEncodingZ32, err))?;
103        let bytes: &[u8; 32] = &bytes
104            .try_into()
105            .map_err(|_| e!(DecodingError::InvalidLength { len: s.len() }))?;
106        let endpoint_id =
107            EndpointId::from_bytes(bytes).map_err(|err| e!(DecodingError::InvalidKey, err))?;
108        Ok(endpoint_id)
109    }
110}
111
112/// Data about an endpoint that may be published to and resolved from discovery services.
113///
114/// This includes an optional [`RelayUrl`], a set of direct addresses, and the optional
115/// [`UserData`], a string that can be set by applications and is not parsed or used by iroh
116/// itself.
117///
118/// This struct does not include the endpoint's [`EndpointId`], only the data *about* a certain
119/// endpoint. See [`EndpointInfo`] for a struct that contains a [`EndpointId`] with associated [`EndpointData`].
120#[derive(Debug, Clone, Default, Eq, PartialEq)]
121pub struct EndpointData {
122    /// addresses where this endpoint can be reached.
123    addrs: BTreeSet<TransportAddr>,
124    /// Optional user-defined [`UserData`] for this endpoint.
125    user_data: Option<UserData>,
126}
127
128impl EndpointData {
129    /// Creates a new [`EndpointData`] with a relay URL and a set of direct addresses.
130    pub fn new(addrs: impl IntoIterator<Item = TransportAddr>) -> Self {
131        Self {
132            addrs: addrs.into_iter().collect(),
133            user_data: None,
134        }
135    }
136
137    /// Sets the relay URL and returns the updated endpoint data.
138    pub fn with_relay_url(mut self, relay_url: Option<RelayUrl>) -> Self {
139        if let Some(url) = relay_url {
140            self.addrs.insert(TransportAddr::Relay(url));
141        }
142        self
143    }
144
145    /// Sets the direct addresses and returns the updated endpoint data.
146    pub fn with_ip_addrs(mut self, addresses: BTreeSet<SocketAddr>) -> Self {
147        for addr in addresses.into_iter() {
148            self.addrs.insert(TransportAddr::Ip(addr));
149        }
150        self
151    }
152
153    /// Sets the user-defined data and returns the updated endpoint data.
154    pub fn with_user_data(mut self, user_data: Option<UserData>) -> Self {
155        self.user_data = user_data;
156        self
157    }
158
159    /// Returns the relay URL of the endpoint.
160    pub fn relay_urls(&self) -> impl Iterator<Item = &RelayUrl> {
161        self.addrs.iter().filter_map(|addr| match addr {
162            TransportAddr::Relay(url) => Some(url),
163            _ => None,
164        })
165    }
166
167    /// Returns the optional user-defined data of the endpoint.
168    pub fn user_data(&self) -> Option<&UserData> {
169        self.user_data.as_ref()
170    }
171
172    /// Returns the direct addresses of the endpoint.
173    pub fn ip_addrs(&self) -> impl Iterator<Item = &SocketAddr> {
174        self.addrs.iter().filter_map(|addr| match addr {
175            TransportAddr::Ip(addr) => Some(addr),
176            _ => None,
177        })
178    }
179
180    /// Removes all direct addresses from the endpoint data.
181    pub fn clear_ip_addrs(&mut self) {
182        self.addrs
183            .retain(|addr| !matches!(addr, TransportAddr::Ip(_)));
184    }
185
186    /// Removes all direct addresses from the endpoint data.
187    pub fn clear_relay_urls(&mut self) {
188        self.addrs
189            .retain(|addr| !matches!(addr, TransportAddr::Relay(_)));
190    }
191
192    /// Add addresses to the endpoint data.
193    pub fn add_addrs(&mut self, addrs: impl IntoIterator<Item = TransportAddr>) {
194        for addr in addrs.into_iter() {
195            self.addrs.insert(addr);
196        }
197    }
198
199    /// Sets the user-defined data of the endpoint data.
200    pub fn set_user_data(&mut self, user_data: Option<UserData>) {
201        self.user_data = user_data;
202    }
203
204    /// Returns the full list of all known addresses
205    pub fn addrs(&self) -> impl Iterator<Item = &TransportAddr> {
206        self.addrs.iter()
207    }
208
209    /// Does this have any addresses?
210    pub fn has_addrs(&self) -> bool {
211        !self.addrs.is_empty()
212    }
213}
214
215impl From<EndpointAddr> for EndpointData {
216    fn from(endpoint_addr: EndpointAddr) -> Self {
217        Self {
218            addrs: endpoint_addr.addrs,
219            user_data: None,
220        }
221    }
222}
223
224// User-defined data that can be published and resolved through endpoint discovery.
225///
226/// Under the hood this is a UTF-8 String is no longer than [`UserData::MAX_LENGTH`] bytes.
227///
228/// Iroh does not keep track of or examine the user-defined data.
229///
230/// `UserData` implements [`FromStr`] and [`TryFrom<String>`], so you can
231/// convert `&str` and `String` into `UserData` easily.
232#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
233pub struct UserData(String);
234
235impl UserData {
236    /// The max byte length allowed for user-defined data.
237    ///
238    /// In DNS discovery services, the user-defined data is stored in a TXT record character string,
239    /// which has a max length of 255 bytes. We need to subtract the `user-data=` prefix,
240    /// which leaves 245 bytes for the actual user-defined data.
241    pub const MAX_LENGTH: usize = 245;
242}
243
244/// Error returned when an input value is too long for [`UserData`].
245#[allow(missing_docs)]
246#[stack_error(derive, add_meta)]
247#[error("max length exceeded")]
248pub struct MaxLengthExceededError {}
249
250impl TryFrom<String> for UserData {
251    type Error = MaxLengthExceededError;
252
253    fn try_from(value: String) -> Result<Self, Self::Error> {
254        ensure!(value.len() <= Self::MAX_LENGTH, MaxLengthExceededError);
255        Ok(Self(value))
256    }
257}
258
259impl FromStr for UserData {
260    type Err = MaxLengthExceededError;
261
262    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
263        ensure!(s.len() <= Self::MAX_LENGTH, MaxLengthExceededError);
264        Ok(Self(s.to_string()))
265    }
266}
267
268impl fmt::Display for UserData {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        write!(f, "{}", self.0)
271    }
272}
273
274impl AsRef<str> for UserData {
275    fn as_ref(&self) -> &str {
276        &self.0
277    }
278}
279
280/// Information about an endpoint that may be published to and resolved from discovery services.
281///
282/// This struct couples a [`EndpointId`] with its associated [`EndpointData`].
283#[derive(derive_more::Debug, Clone, Eq, PartialEq)]
284pub struct EndpointInfo {
285    /// The [`EndpointId`] of the endpoint this is about.
286    pub endpoint_id: EndpointId,
287    /// The information published about the endpoint.
288    pub data: EndpointData,
289}
290
291impl From<TxtAttrs<IrohAttr>> for EndpointInfo {
292    fn from(attrs: TxtAttrs<IrohAttr>) -> Self {
293        (&attrs).into()
294    }
295}
296
297impl From<&TxtAttrs<IrohAttr>> for EndpointInfo {
298    fn from(attrs: &TxtAttrs<IrohAttr>) -> Self {
299        let endpoint_id = attrs.endpoint_id();
300        let attrs = attrs.attrs();
301        let relay_urls = attrs
302            .get(&IrohAttr::Relay)
303            .into_iter()
304            .flatten()
305            .filter_map(|s| Url::parse(s).ok())
306            .map(|url| TransportAddr::Relay(url.into()));
307        let ip_addrs = attrs
308            .get(&IrohAttr::Addr)
309            .into_iter()
310            .flatten()
311            .filter_map(|s| SocketAddr::from_str(s).ok())
312            .map(TransportAddr::Ip);
313
314        let user_data = attrs
315            .get(&IrohAttr::UserData)
316            .into_iter()
317            .flatten()
318            .next()
319            .and_then(|s| UserData::from_str(s).ok());
320        let mut data = EndpointData::default();
321        data.set_user_data(user_data);
322        data.add_addrs(relay_urls.chain(ip_addrs));
323
324        Self { endpoint_id, data }
325    }
326}
327
328impl From<EndpointInfo> for EndpointAddr {
329    fn from(value: EndpointInfo) -> Self {
330        value.into_endpoint_addr()
331    }
332}
333
334impl From<EndpointAddr> for EndpointInfo {
335    fn from(addr: EndpointAddr) -> Self {
336        let mut info = Self::new(addr.id);
337        info.add_addrs(addr.addrs);
338        info
339    }
340}
341
342impl EndpointInfo {
343    /// Creates a new [`EndpointInfo`] with an empty [`EndpointData`].
344    pub fn new(endpoint_id: EndpointId) -> Self {
345        Self::from_parts(endpoint_id, Default::default())
346    }
347
348    /// Creates a new [`EndpointInfo`] from its parts.
349    pub fn from_parts(endpoint_id: EndpointId, data: EndpointData) -> Self {
350        Self { endpoint_id, data }
351    }
352
353    /// Sets the relay URL and returns the updated endpoint info.
354    pub fn with_relay_url(mut self, relay_url: Option<RelayUrl>) -> Self {
355        self.data = self.data.with_relay_url(relay_url);
356        self
357    }
358
359    /// Sets the IP based addresses and returns the updated endpoint info.
360    pub fn with_ip_addrs(mut self, addrs: BTreeSet<SocketAddr>) -> Self {
361        self.data = self.data.with_ip_addrs(addrs);
362        self
363    }
364
365    /// Sets the user-defined data and returns the updated endpoint info.
366    pub fn with_user_data(mut self, user_data: Option<UserData>) -> Self {
367        self.data = self.data.with_user_data(user_data);
368        self
369    }
370
371    /// Converts into a [`EndpointAddr`] by cloning the needed fields.
372    pub fn to_endpoint_addr(&self) -> EndpointAddr {
373        EndpointAddr {
374            id: self.endpoint_id,
375            addrs: self.addrs.clone(),
376        }
377    }
378
379    /// Converts into a [`EndpointAddr`] without cloning.
380    pub fn into_endpoint_addr(self) -> EndpointAddr {
381        let Self { endpoint_id, data } = self;
382        EndpointAddr {
383            id: endpoint_id,
384            addrs: data.addrs,
385        }
386    }
387
388    fn to_attrs(&self) -> TxtAttrs<IrohAttr> {
389        self.into()
390    }
391
392    #[cfg(not(wasm_browser))]
393    /// Parses a [`EndpointInfo`] from DNS TXT lookup.
394    pub fn from_txt_lookup(
395        domain_name: String,
396        lookup: impl Iterator<Item = crate::dns::TxtRecordData>,
397    ) -> Result<Self, ParseError> {
398        let attrs = TxtAttrs::from_txt_lookup(domain_name, lookup)?;
399        Ok(Self::from(attrs))
400    }
401
402    /// Parses a [`EndpointInfo`] from a [`pkarr::SignedPacket`].
403    pub fn from_pkarr_signed_packet(packet: &pkarr::SignedPacket) -> Result<Self, ParseError> {
404        let attrs = TxtAttrs::from_pkarr_signed_packet(packet)?;
405        Ok(attrs.into())
406    }
407
408    /// Creates a [`pkarr::SignedPacket`].
409    ///
410    /// This constructs a DNS packet and signs it with a [`SecretKey`].
411    pub fn to_pkarr_signed_packet(
412        &self,
413        secret_key: &SecretKey,
414        ttl: u32,
415    ) -> Result<pkarr::SignedPacket, EncodingError> {
416        self.to_attrs().to_pkarr_signed_packet(secret_key, ttl)
417    }
418
419    /// Converts into a list of `{key}={value}` strings.
420    pub fn to_txt_strings(&self) -> Vec<String> {
421        self.to_attrs().to_txt_strings().collect()
422    }
423}
424
425#[allow(missing_docs)]
426#[stack_error(derive, add_meta, from_sources)]
427#[non_exhaustive]
428pub enum ParseError {
429    #[error("Expected format `key=value`, received `{s}`")]
430    UnexpectedFormat { s: String },
431    #[error("Could not convert key to Attr")]
432    AttrFromString { key: String },
433    #[error("Expected 2 labels, received {num_labels}")]
434    NumLabels { num_labels: usize },
435    #[error("Could not parse labels")]
436    Utf8 {
437        #[error(std_err)]
438        source: Utf8Error,
439    },
440    #[error("Record is not an `iroh` record, expected `_iroh`, got `{label}`")]
441    NotAnIrohRecord { label: String },
442    #[error(transparent)]
443    DecodingError { source: DecodingError },
444}
445
446impl std::ops::Deref for EndpointInfo {
447    type Target = EndpointData;
448    fn deref(&self) -> &Self::Target {
449        &self.data
450    }
451}
452
453impl std::ops::DerefMut for EndpointInfo {
454    fn deref_mut(&mut self) -> &mut Self::Target {
455        &mut self.data
456    }
457}
458
459/// Parses a [`EndpointId`] from iroh DNS name.
460///
461/// Takes a [`hickory_resolver::proto::rr::Name`] DNS name and expects the first label to be
462/// [`IROH_TXT_NAME`] and the second label to be a z32 encoded [`EndpointId`]. Ignores
463/// subsequent labels.
464#[cfg(not(wasm_browser))]
465fn endpoint_id_from_txt_name(name: &str) -> Result<EndpointId, ParseError> {
466    let num_labels = name.split(".").count();
467    if num_labels < 2 {
468        return Err(e!(ParseError::NumLabels { num_labels }));
469    }
470    let mut labels = name.split(".");
471    let label = labels.next().expect("checked above");
472    if label != IROH_TXT_NAME {
473        return Err(e!(ParseError::NotAnIrohRecord {
474            label: label.to_string()
475        }));
476    }
477    let label = labels.next().expect("checked above");
478    let endpoint_id = EndpointId::from_z32(label)?;
479    Ok(endpoint_id)
480}
481
482/// The attributes supported by iroh for [`IROH_TXT_NAME`] DNS resource records.
483///
484/// The resource record uses the lower-case names.
485#[derive(
486    Debug, strum::Display, strum::AsRefStr, strum::EnumString, Hash, Eq, PartialEq, Ord, PartialOrd,
487)]
488#[strum(serialize_all = "kebab-case")]
489pub(crate) enum IrohAttr {
490    /// URL of home relay.
491    Relay,
492    /// Direct address.
493    Addr,
494    /// User-defined data
495    UserData,
496}
497
498/// Attributes parsed from [`IROH_TXT_NAME`] TXT records.
499///
500/// This struct is generic over the key type. When using with [`String`], this will parse
501/// all attributes. Can also be used with an enum, if it implements [`FromStr`] and
502/// [`Display`].
503#[derive(Debug)]
504pub(crate) struct TxtAttrs<T> {
505    endpoint_id: EndpointId,
506    attrs: BTreeMap<T, Vec<String>>,
507}
508
509impl From<&EndpointInfo> for TxtAttrs<IrohAttr> {
510    fn from(info: &EndpointInfo) -> Self {
511        let mut attrs = vec![];
512        for addr in &info.data.addrs {
513            match addr {
514                TransportAddr::Relay(relay_url) => {
515                    attrs.push((IrohAttr::Relay, relay_url.to_string()))
516                }
517                TransportAddr::Ip(addr) => attrs.push((IrohAttr::Addr, addr.to_string())),
518                _ => {}
519            }
520        }
521
522        if let Some(user_data) = &info.data.user_data {
523            attrs.push((IrohAttr::UserData, user_data.to_string()));
524        }
525        Self::from_parts(info.endpoint_id, attrs.into_iter())
526    }
527}
528
529impl<T: FromStr + Display + Hash + Ord> TxtAttrs<T> {
530    /// Creates [`TxtAttrs`] from an endpoint id and an iterator of key-value pairs.
531    pub(crate) fn from_parts(
532        endpoint_id: EndpointId,
533        pairs: impl Iterator<Item = (T, String)>,
534    ) -> Self {
535        let mut attrs: BTreeMap<T, Vec<String>> = BTreeMap::new();
536        for (k, v) in pairs {
537            attrs.entry(k).or_default().push(v);
538        }
539        Self { attrs, endpoint_id }
540    }
541
542    /// Creates [`TxtAttrs`] from an endpoint id and an iterator of "{key}={value}" strings.
543    pub(crate) fn from_strings(
544        endpoint_id: EndpointId,
545        strings: impl Iterator<Item = String>,
546    ) -> Result<Self, ParseError> {
547        let mut attrs: BTreeMap<T, Vec<String>> = BTreeMap::new();
548        for s in strings {
549            let mut parts = s.split('=');
550            let (Some(key), Some(value)) = (parts.next(), parts.next()) else {
551                return Err(e!(ParseError::UnexpectedFormat { s }));
552            };
553            let attr = T::from_str(key).map_err(|_| {
554                e!(ParseError::AttrFromString {
555                    key: key.to_string()
556                })
557            })?;
558            attrs.entry(attr).or_default().push(value.to_string());
559        }
560        Ok(Self { attrs, endpoint_id })
561    }
562
563    /// Returns the parsed attributes.
564    pub(crate) fn attrs(&self) -> &BTreeMap<T, Vec<String>> {
565        &self.attrs
566    }
567
568    /// Returns the endpoint id.
569    pub(crate) fn endpoint_id(&self) -> EndpointId {
570        self.endpoint_id
571    }
572
573    /// Parses a [`pkarr::SignedPacket`].
574    pub(crate) fn from_pkarr_signed_packet(
575        packet: &pkarr::SignedPacket,
576    ) -> Result<Self, ParseError> {
577        use pkarr::dns::{
578            rdata::RData,
579            {self},
580        };
581        let pubkey = packet.public_key();
582        let pubkey_z32 = pubkey.to_z32();
583        let endpoint_id =
584            EndpointId::from_bytes(&pubkey.verifying_key().to_bytes()).expect("valid key");
585        let zone = dns::Name::new(&pubkey_z32).expect("z32 encoding is valid");
586        let txt_data = packet
587            .all_resource_records()
588            .filter_map(|rr| match &rr.rdata {
589                RData::TXT(txt) => match rr.name.without(&zone) {
590                    Some(name) if name.to_string() == IROH_TXT_NAME => Some(txt),
591                    Some(_) | None => None,
592                },
593                _ => None,
594            });
595
596        let txt_strs = txt_data.filter_map(|s| String::try_from(s.clone()).ok());
597        Self::from_strings(endpoint_id, txt_strs)
598    }
599
600    /// Parses a TXT records lookup.
601    #[cfg(not(wasm_browser))]
602    pub(crate) fn from_txt_lookup(
603        name: String,
604        lookup: impl Iterator<Item = crate::dns::TxtRecordData>,
605    ) -> Result<Self, ParseError> {
606        let queried_endpoint_id = endpoint_id_from_txt_name(&name)?;
607
608        let strings = lookup.map(|record| record.to_string());
609        Self::from_strings(queried_endpoint_id, strings)
610    }
611
612    fn to_txt_strings(&self) -> impl Iterator<Item = String> + '_ {
613        self.attrs
614            .iter()
615            .flat_map(move |(k, vs)| vs.iter().map(move |v| format!("{k}={v}")))
616    }
617
618    /// Creates a [`pkarr::SignedPacket`]
619    ///
620    /// This constructs a DNS packet and signs it with a [`SecretKey`].
621    pub(crate) fn to_pkarr_signed_packet(
622        &self,
623        secret_key: &SecretKey,
624        ttl: u32,
625    ) -> Result<pkarr::SignedPacket, EncodingError> {
626        use pkarr::dns::{self, rdata};
627        let keypair = pkarr::Keypair::from_secret_key(&secret_key.to_bytes());
628        let name = dns::Name::new(IROH_TXT_NAME).expect("constant");
629
630        let mut builder = pkarr::SignedPacket::builder();
631        for s in self.to_txt_strings() {
632            let mut txt = rdata::TXT::new();
633            txt.add_string(&s)
634                .map_err(|err| e!(EncodingError::InvalidTxtEntry, err))?;
635            builder = builder.txt(name.clone(), txt.into_owned(), ttl);
636        }
637        let signed_packet = builder
638            .build(&keypair)
639            .map_err(|err| e!(EncodingError::FailedBuildingPacket, err))?;
640        Ok(signed_packet)
641    }
642}
643
644#[cfg(not(wasm_browser))]
645pub(crate) fn ensure_iroh_txt_label(name: String) -> String {
646    let mut parts = name.split(".");
647    if parts.next() == Some(IROH_TXT_NAME) {
648        name
649    } else {
650        format!("{IROH_TXT_NAME}.{name}")
651    }
652}
653
654#[cfg(not(wasm_browser))]
655pub(crate) fn endpoint_domain(endpoint_id: &EndpointId, origin: &str) -> String {
656    format!("{}.{}", EndpointId::to_z32(endpoint_id), origin)
657}
658
659#[cfg(test)]
660mod tests {
661    use std::{collections::BTreeSet, str::FromStr, sync::Arc};
662
663    use hickory_resolver::{
664        Name,
665        lookup::Lookup,
666        proto::{
667            op::Query,
668            rr::{
669                RData, Record, RecordType,
670                rdata::{A, TXT},
671            },
672        },
673    };
674    use iroh_base::{EndpointId, SecretKey, TransportAddr};
675    use n0_error::{Result, StdResultExt};
676
677    use super::{EndpointData, EndpointIdExt, EndpointInfo};
678    use crate::dns::TxtRecordData;
679
680    #[test]
681    fn txt_attr_roundtrip() {
682        let endpoint_data = EndpointData::new([
683            TransportAddr::Relay("https://example.com".parse().unwrap()),
684            TransportAddr::Ip("127.0.0.1:1234".parse().unwrap()),
685        ])
686        .with_user_data(Some("foobar".parse().unwrap()));
687        let endpoint_id = "vpnk377obfvzlipnsfbqba7ywkkenc4xlpmovt5tsfujoa75zqia"
688            .parse()
689            .unwrap();
690        let expected = EndpointInfo::from_parts(endpoint_id, endpoint_data);
691        let attrs = expected.to_attrs();
692        let actual = EndpointInfo::from(&attrs);
693        assert_eq!(expected, actual);
694    }
695
696    #[test]
697    fn signed_packet_roundtrip() {
698        let secret_key =
699            SecretKey::from_str("vpnk377obfvzlipnsfbqba7ywkkenc4xlpmovt5tsfujoa75zqia").unwrap();
700        let endpoint_data = EndpointData::new([
701            TransportAddr::Relay("https://example.com".parse().unwrap()),
702            TransportAddr::Ip("127.0.0.1:1234".parse().unwrap()),
703        ])
704        .with_user_data(Some("foobar".parse().unwrap()));
705        let expected = EndpointInfo::from_parts(secret_key.public(), endpoint_data);
706        let packet = expected.to_pkarr_signed_packet(&secret_key, 30).unwrap();
707        let actual = EndpointInfo::from_pkarr_signed_packet(&packet).unwrap();
708        assert_eq!(expected, actual);
709    }
710
711    /// There used to be a bug where uploading an EndpointAddr with more than only exactly
712    /// one relay URL or one publicly reachable IP addr would prevent connection
713    /// establishment.
714    ///
715    /// The reason was that only the first address was parsed (e.g. 192.168.96.145 in
716    /// this example), which could be a local, unreachable address.
717    #[test]
718    fn test_from_hickory_lookup() -> Result {
719        let name = Name::from_utf8(
720            "_iroh.dgjpkxyn3zyrk3zfads5duwdgbqpkwbjxfj4yt7rezidr3fijccy.dns.iroh.link.",
721        )
722        .std_context("dns name")?;
723        let query = Query::query(name.clone(), RecordType::TXT);
724        let records = [
725            Record::from_rdata(
726                name.clone(),
727                30,
728                RData::TXT(TXT::new(vec!["addr=192.168.96.145:60165".to_string()])),
729            ),
730            Record::from_rdata(
731                name.clone(),
732                30,
733                RData::TXT(TXT::new(vec!["addr=213.208.157.87:60165".to_string()])),
734            ),
735            // Test a record with mismatching record type (A instead of TXT). It should be filtered out.
736            Record::from_rdata(name.clone(), 30, RData::A(A::new(127, 0, 0, 1))),
737            // Test a record with a mismatching name
738            Record::from_rdata(
739                Name::from_utf8(format!(
740                    "_iroh.{}.dns.iroh.link.",
741                    EndpointId::from_str(
742                        // Another EndpointId
743                        "a55f26132e5e43de834d534332f66a20d480c3e50a13a312a071adea6569981e"
744                    )?
745                    .to_z32()
746                ))
747                .std_context("name")?,
748                30,
749                RData::TXT(TXT::new(vec![
750                    "relay=https://euw1-1.relay.iroh.network./".to_string(),
751                ])),
752            ),
753            // Test a record with a completely different name
754            Record::from_rdata(
755                Name::from_utf8("dns.iroh.link.").std_context("name")?,
756                30,
757                RData::TXT(TXT::new(vec![
758                    "relay=https://euw1-1.relay.iroh.network./".to_string(),
759                ])),
760            ),
761            Record::from_rdata(
762                name.clone(),
763                30,
764                RData::TXT(TXT::new(vec![
765                    "relay=https://euw1-1.relay.iroh.network./".to_string(),
766                ])),
767            ),
768        ];
769        let lookup = Lookup::new_with_max_ttl(query, Arc::new(records));
770        let lookup = hickory_resolver::lookup::TxtLookup::from(lookup);
771        let lookup = lookup
772            .into_iter()
773            .map(|txt| TxtRecordData::from_iter(txt.iter().cloned()));
774
775        let endpoint_info = EndpointInfo::from_txt_lookup(name.to_string(), lookup)?;
776
777        let expected_endpoint_info = EndpointInfo::new(EndpointId::from_str(
778            "1992d53c02cdc04566e5c0edb1ce83305cd550297953a047a445ea3264b54b18",
779        )?)
780        .with_relay_url(Some("https://euw1-1.relay.iroh.network./".parse()?))
781        .with_ip_addrs(BTreeSet::from([
782            "192.168.96.145:60165".parse().unwrap(),
783            "213.208.157.87:60165".parse().unwrap(),
784        ]));
785
786        assert_eq!(endpoint_info, expected_endpoint_info);
787
788        Ok(())
789    }
790}