Skip to main content

iroh_dns/
attrs.rs

1//! Support for handling DNS resource records for dialing by [`EndpointId`].
2//!
3//! DNS records are published under the following names:
4//!
5//! `_iroh.<z32-endpoint-id>.<origin-domain> TXT`
6//!
7//! The returned TXT records must contain a string value of the form `key=value` as defined
8//! in [RFC1464].
9//!
10//! [RFC1464]: https://www.rfc-editor.org/rfc/rfc1464
11
12use std::{collections::BTreeMap, fmt::Display, hash::Hash, str::FromStr};
13
14use iroh_base::{EndpointId, SecretKey};
15use n0_error::{e, stack_error};
16
17use crate::pkarr;
18
19/// The DNS name for the iroh TXT record.
20pub const IROH_TXT_NAME: &str = "_iroh";
21
22#[allow(missing_docs)]
23#[stack_error(derive, add_meta)]
24#[non_exhaustive]
25pub enum EncodingError {
26    #[error(transparent)]
27    FailedBuildingPacket {
28        #[error(std_err)]
29        source: pkarr::SignedPacketBuildError,
30    },
31}
32
33#[allow(missing_docs)]
34#[stack_error(derive, add_meta, from_sources)]
35#[non_exhaustive]
36pub enum ParseError {
37    #[error("Expected format `key=value`, received `{s}`")]
38    UnexpectedFormat { s: String },
39    #[error("Could not convert key to Attr")]
40    AttrFromString { key: String },
41    #[error("Expected 2 labels, received {num_labels}")]
42    NumLabels { num_labels: usize },
43    #[error("Could not parse labels")]
44    Utf8 {
45        #[error(std_err)]
46        source: std::str::Utf8Error,
47    },
48    #[error("Record is not an `iroh` record, expected `_iroh`, got `{label}`")]
49    NotAnIrohRecord { label: String },
50    #[error(transparent)]
51    DecodingError { source: iroh_base::KeyParsingError },
52}
53
54/// Parses a [`EndpointId`] from iroh DNS name.
55///
56/// Takes a DNS name and expects the first label to be [`IROH_TXT_NAME`] and the second
57/// label to be a z32 encoded [`EndpointId`]. Ignores subsequent labels.
58pub(crate) fn endpoint_id_from_txt_name(name: &str) -> Result<EndpointId, ParseError> {
59    let num_labels = name.split(".").count();
60    if num_labels < 2 {
61        return Err(e!(ParseError::NumLabels { num_labels }));
62    }
63    let mut labels = name.split(".");
64    let label = labels.next().expect("checked above");
65    if label != IROH_TXT_NAME {
66        return Err(e!(ParseError::NotAnIrohRecord {
67            label: label.to_string()
68        }));
69    }
70    let label = labels.next().expect("checked above");
71    let endpoint_id = EndpointId::from_z32(label)?;
72    Ok(endpoint_id)
73}
74
75/// The attributes supported by iroh for [`IROH_TXT_NAME`] DNS resource records.
76///
77/// The resource record uses the lower-case names.
78#[derive(
79    Debug, strum::Display, strum::AsRefStr, strum::EnumString, Hash, Eq, PartialEq, Ord, PartialOrd,
80)]
81#[strum(serialize_all = "kebab-case")]
82pub(crate) enum IrohAttr {
83    /// URL of home relay.
84    Relay,
85    /// Address (IP or custom transport).
86    Addr,
87    /// User-defined data
88    UserData,
89}
90
91/// Attributes parsed from [`IROH_TXT_NAME`] TXT records.
92///
93/// This struct is generic over the key type. When using with [`String`], this will parse
94/// all attributes. Can also be used with an enum, if it implements [`FromStr`] and
95/// [`Display`].
96#[derive(Debug)]
97pub(crate) struct TxtAttrs<T> {
98    endpoint_id: EndpointId,
99    attrs: BTreeMap<T, Vec<String>>,
100}
101
102impl<T: FromStr + Display + Hash + Ord> TxtAttrs<T> {
103    /// Creates [`TxtAttrs`] from an endpoint id and an iterator of key-value pairs.
104    pub(crate) fn from_parts(
105        endpoint_id: EndpointId,
106        pairs: impl Iterator<Item = (T, String)>,
107    ) -> Self {
108        let mut attrs: BTreeMap<T, Vec<String>> = BTreeMap::new();
109        for (k, v) in pairs {
110            attrs.entry(k).or_default().push(v);
111        }
112        Self { attrs, endpoint_id }
113    }
114
115    /// Creates [`TxtAttrs`] from an endpoint id and an iterator of "{key}={value}" strings.
116    pub(crate) fn from_strings(
117        endpoint_id: EndpointId,
118        strings: impl Iterator<Item = String>,
119    ) -> Result<Self, ParseError> {
120        let mut attrs: BTreeMap<T, Vec<String>> = BTreeMap::new();
121        for s in strings {
122            let mut parts = s.split('=');
123            let (Some(key), Some(value)) = (parts.next(), parts.next()) else {
124                return Err(e!(ParseError::UnexpectedFormat { s }));
125            };
126            let attr = T::from_str(key).map_err(|_| {
127                e!(ParseError::AttrFromString {
128                    key: key.to_string()
129                })
130            })?;
131            attrs.entry(attr).or_default().push(value.to_string());
132        }
133        Ok(Self { attrs, endpoint_id })
134    }
135
136    /// Returns the parsed attributes.
137    pub(crate) fn attrs(&self) -> &BTreeMap<T, Vec<String>> {
138        &self.attrs
139    }
140
141    /// Returns the endpoint id.
142    pub(crate) fn endpoint_id(&self) -> EndpointId {
143        self.endpoint_id
144    }
145
146    /// Parses TXT record lookup results.
147    ///
148    /// The `name` is the queried DNS name. The `lookup` iterator yields TXT record
149    /// values that implement [`Display`].
150    pub(crate) fn from_txt_lookup(
151        name: String,
152        lookup: impl Iterator<Item = impl Display>,
153    ) -> Result<Self, ParseError> {
154        let queried_endpoint_id = endpoint_id_from_txt_name(&name)?;
155        let strings = lookup.map(|record| record.to_string());
156        Self::from_strings(queried_endpoint_id, strings)
157    }
158
159    /// Parses a [`pkarr::SignedPacket`].
160    pub(crate) fn from_pkarr_signed_packet(
161        packet: &pkarr::SignedPacket,
162    ) -> Result<Self, ParseError> {
163        let pubkey = packet.public_key();
164        let endpoint_id = EndpointId::from_bytes(pubkey.as_bytes()).expect("valid key");
165        let txt_strs = packet.txt_records(IROH_TXT_NAME);
166        Self::from_strings(endpoint_id, txt_strs.into_iter())
167    }
168
169    /// Converts to `{key}={value}` strings.
170    pub(crate) fn to_txt_strings(&self) -> impl Iterator<Item = String> + '_ {
171        self.attrs
172            .iter()
173            .flat_map(move |(k, vs)| vs.iter().map(move |v| format!("{k}={v}")))
174    }
175
176    /// Creates a [`pkarr::SignedPacket`]
177    ///
178    /// This constructs a DNS packet and signs it with a [`SecretKey`].
179    pub(crate) fn to_pkarr_signed_packet(
180        &self,
181        secret_key: &SecretKey,
182        ttl: u32,
183    ) -> Result<pkarr::SignedPacket, EncodingError> {
184        let signed_packet = pkarr::SignedPacket::from_txt_strings(
185            secret_key,
186            IROH_TXT_NAME,
187            self.to_txt_strings(),
188            ttl,
189        )
190        .map_err(|err| e!(EncodingError::FailedBuildingPacket, err))?;
191        Ok(signed_packet)
192    }
193}