Skip to main content

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