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 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 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 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 fn from_parts(endpoint_id: EndpointId, pairs: impl Iterator<Item = (T, String)>) -> Self {
105        let mut attrs: BTreeMap<T, Vec<String>> = BTreeMap::new();
106        for (k, v) in pairs {
107            attrs.entry(k).or_default().push(v);
108        }
109        Self { attrs, endpoint_id }
110    }
111
112    /// Creates [`TxtAttrs`] from an endpoint id and an iterator of "{key}={value}" strings.
113    pub fn from_strings(
114        endpoint_id: EndpointId,
115        strings: impl Iterator<Item = String>,
116    ) -> Result<Self, ParseError> {
117        let mut attrs: BTreeMap<T, Vec<String>> = BTreeMap::new();
118        for s in strings {
119            let mut parts = s.split('=');
120            let (Some(key), Some(value)) = (parts.next(), parts.next()) else {
121                return Err(e!(ParseError::UnexpectedFormat { s }));
122            };
123            let attr = T::from_str(key).map_err(|_| {
124                e!(ParseError::AttrFromString {
125                    key: key.to_string()
126                })
127            })?;
128            attrs.entry(attr).or_default().push(value.to_string());
129        }
130        Ok(Self { attrs, endpoint_id })
131    }
132
133    /// Returns the parsed attributes.
134    pub fn attrs(&self) -> &BTreeMap<T, Vec<String>> {
135        &self.attrs
136    }
137
138    /// Returns the endpoint id.
139    pub fn endpoint_id(&self) -> EndpointId {
140        self.endpoint_id
141    }
142
143    /// Parses TXT record lookup results.
144    ///
145    /// The `name` is the queried DNS name. The `lookup` iterator yields TXT record
146    /// values that implement [`Display`].
147    pub fn from_txt_lookup(
148        name: String,
149        lookup: impl Iterator<Item = impl Display>,
150    ) -> Result<Self, ParseError> {
151        let queried_endpoint_id = endpoint_id_from_txt_name(&name)?;
152        let strings = lookup.map(|record| record.to_string());
153        Self::from_strings(queried_endpoint_id, strings)
154    }
155
156    /// Parses a [`pkarr::SignedPacket`].
157    pub fn from_pkarr_signed_packet(packet: &pkarr::SignedPacket) -> Result<Self, ParseError> {
158        let pubkey = packet.public_key();
159        let endpoint_id = EndpointId::from_bytes(pubkey.as_bytes()).expect("valid key");
160        let txt_strs = packet.txt_records(IROH_TXT_NAME);
161        Self::from_strings(endpoint_id, txt_strs.into_iter())
162    }
163
164    /// Converts to `{key}={value}` strings.
165    pub fn to_txt_strings(&self) -> impl Iterator<Item = String> + '_ {
166        self.attrs
167            .iter()
168            .flat_map(move |(k, vs)| vs.iter().map(move |v| format!("{k}={v}")))
169    }
170
171    /// Creates a [`pkarr::SignedPacket`]
172    ///
173    /// This constructs a DNS packet and signs it with a [`SecretKey`].
174    pub fn to_pkarr_signed_packet(
175        &self,
176        secret_key: &SecretKey,
177        ttl: u32,
178    ) -> Result<pkarr::SignedPacket, EncodingError> {
179        let signed_packet = pkarr::SignedPacket::from_txt_strings(
180            secret_key,
181            IROH_TXT_NAME,
182            self.to_txt_strings(),
183            ttl,
184        )
185        .map_err(|err| e!(EncodingError::FailedBuildingPacket, err))?;
186        Ok(signed_packet)
187    }
188}