use std::{
collections::{BTreeMap, BTreeSet},
fmt::{self, Display},
hash::Hash,
net::SocketAddr,
str::{FromStr, Utf8Error},
};
use iroh_base::{EndpointAddr, EndpointId, KeyParsingError, RelayUrl, SecretKey, TransportAddr};
use n0_error::{e, ensure, stack_error};
use url::Url;
pub const IROH_TXT_NAME: &str = "_iroh";
#[allow(missing_docs)]
#[stack_error(derive, add_meta)]
#[non_exhaustive]
pub enum EncodingError {
#[error(transparent)]
FailedBuildingPacket {
#[error(std_err)]
source: pkarr::errors::SignedPacketBuildError,
},
#[error("invalid TXT entry")]
InvalidTxtEntry {
#[error(std_err)]
source: pkarr::dns::SimpleDnsError,
},
}
#[allow(missing_docs)]
#[stack_error(derive, add_meta)]
#[non_exhaustive]
pub enum DecodingError {
#[error("endpoint id was not encoded in valid z32")]
InvalidEncodingZ32 {
#[error(std_err)]
source: z32::Z32Error,
},
#[error("length must be 32 bytes, but got {len} byte(s)")]
InvalidLength { len: usize },
#[error("endpoint id is not a valid public key")]
InvalidKey { source: KeyParsingError },
}
pub trait EndpointIdExt {
fn to_z32(&self) -> String;
fn from_z32(s: &str) -> Result<EndpointId, DecodingError>;
}
impl EndpointIdExt for EndpointId {
fn to_z32(&self) -> String {
z32::encode(self.as_bytes())
}
fn from_z32(s: &str) -> Result<EndpointId, DecodingError> {
let bytes =
z32::decode(s.as_bytes()).map_err(|err| e!(DecodingError::InvalidEncodingZ32, err))?;
let bytes: &[u8; 32] = &bytes
.try_into()
.map_err(|_| e!(DecodingError::InvalidLength { len: s.len() }))?;
let endpoint_id =
EndpointId::from_bytes(bytes).map_err(|err| e!(DecodingError::InvalidKey, err))?;
Ok(endpoint_id)
}
}
#[derive(Debug, Clone, Default, Eq, PartialEq)]
pub struct EndpointData {
addrs: BTreeSet<TransportAddr>,
user_data: Option<UserData>,
}
impl EndpointData {
pub fn new(addrs: impl IntoIterator<Item = TransportAddr>) -> Self {
Self {
addrs: addrs.into_iter().collect(),
user_data: None,
}
}
pub fn with_relay_url(mut self, relay_url: Option<RelayUrl>) -> Self {
if let Some(url) = relay_url {
self.addrs.insert(TransportAddr::Relay(url));
}
self
}
pub fn with_ip_addrs(mut self, addresses: BTreeSet<SocketAddr>) -> Self {
for addr in addresses.into_iter() {
self.addrs.insert(TransportAddr::Ip(addr));
}
self
}
pub fn with_user_data(mut self, user_data: Option<UserData>) -> Self {
self.user_data = user_data;
self
}
pub fn relay_urls(&self) -> impl Iterator<Item = &RelayUrl> {
self.addrs.iter().filter_map(|addr| match addr {
TransportAddr::Relay(url) => Some(url),
_ => None,
})
}
pub fn user_data(&self) -> Option<&UserData> {
self.user_data.as_ref()
}
pub fn ip_addrs(&self) -> impl Iterator<Item = &SocketAddr> {
self.addrs.iter().filter_map(|addr| match addr {
TransportAddr::Ip(addr) => Some(addr),
_ => None,
})
}
pub fn clear_ip_addrs(&mut self) {
self.addrs
.retain(|addr| !matches!(addr, TransportAddr::Ip(_)));
}
pub fn clear_relay_urls(&mut self) {
self.addrs
.retain(|addr| !matches!(addr, TransportAddr::Relay(_)));
}
pub fn add_addrs(&mut self, addrs: impl IntoIterator<Item = TransportAddr>) {
for addr in addrs.into_iter() {
self.addrs.insert(addr);
}
}
pub fn set_user_data(&mut self, user_data: Option<UserData>) {
self.user_data = user_data;
}
pub fn addrs(&self) -> impl Iterator<Item = &TransportAddr> {
self.addrs.iter()
}
pub fn has_addrs(&self) -> bool {
!self.addrs.is_empty()
}
}
impl From<EndpointAddr> for EndpointData {
fn from(endpoint_addr: EndpointAddr) -> Self {
Self {
addrs: endpoint_addr.addrs,
user_data: None,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct UserData(String);
impl UserData {
pub const MAX_LENGTH: usize = 245;
}
#[allow(missing_docs)]
#[stack_error(derive, add_meta)]
#[error("max length exceeded")]
pub struct MaxLengthExceededError {}
impl TryFrom<String> for UserData {
type Error = MaxLengthExceededError;
fn try_from(value: String) -> Result<Self, Self::Error> {
ensure!(value.len() <= Self::MAX_LENGTH, MaxLengthExceededError);
Ok(Self(value))
}
}
impl FromStr for UserData {
type Err = MaxLengthExceededError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
ensure!(s.len() <= Self::MAX_LENGTH, MaxLengthExceededError);
Ok(Self(s.to_string()))
}
}
impl fmt::Display for UserData {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for UserData {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(derive_more::Debug, Clone, Eq, PartialEq)]
pub struct EndpointInfo {
pub endpoint_id: EndpointId,
pub data: EndpointData,
}
impl From<TxtAttrs<IrohAttr>> for EndpointInfo {
fn from(attrs: TxtAttrs<IrohAttr>) -> Self {
(&attrs).into()
}
}
impl From<&TxtAttrs<IrohAttr>> for EndpointInfo {
fn from(attrs: &TxtAttrs<IrohAttr>) -> Self {
let endpoint_id = attrs.endpoint_id();
let attrs = attrs.attrs();
let relay_urls = attrs
.get(&IrohAttr::Relay)
.into_iter()
.flatten()
.filter_map(|s| Url::parse(s).ok())
.map(|url| TransportAddr::Relay(url.into()));
let ip_addrs = attrs
.get(&IrohAttr::Addr)
.into_iter()
.flatten()
.filter_map(|s| SocketAddr::from_str(s).ok())
.map(TransportAddr::Ip);
let user_data = attrs
.get(&IrohAttr::UserData)
.into_iter()
.flatten()
.next()
.and_then(|s| UserData::from_str(s).ok());
let mut data = EndpointData::default();
data.set_user_data(user_data);
data.add_addrs(relay_urls.chain(ip_addrs));
Self { endpoint_id, data }
}
}
impl From<EndpointInfo> for EndpointAddr {
fn from(value: EndpointInfo) -> Self {
value.into_endpoint_addr()
}
}
impl From<EndpointAddr> for EndpointInfo {
fn from(addr: EndpointAddr) -> Self {
let mut info = Self::new(addr.id);
info.add_addrs(addr.addrs);
info
}
}
impl EndpointInfo {
pub fn new(endpoint_id: EndpointId) -> Self {
Self::from_parts(endpoint_id, Default::default())
}
pub fn from_parts(endpoint_id: EndpointId, data: EndpointData) -> Self {
Self { endpoint_id, data }
}
pub fn with_relay_url(mut self, relay_url: Option<RelayUrl>) -> Self {
self.data = self.data.with_relay_url(relay_url);
self
}
pub fn with_ip_addrs(mut self, addrs: BTreeSet<SocketAddr>) -> Self {
self.data = self.data.with_ip_addrs(addrs);
self
}
pub fn with_user_data(mut self, user_data: Option<UserData>) -> Self {
self.data = self.data.with_user_data(user_data);
self
}
pub fn to_endpoint_addr(&self) -> EndpointAddr {
EndpointAddr {
id: self.endpoint_id,
addrs: self.addrs.clone(),
}
}
pub fn into_endpoint_addr(self) -> EndpointAddr {
let Self { endpoint_id, data } = self;
EndpointAddr {
id: endpoint_id,
addrs: data.addrs,
}
}
fn to_attrs(&self) -> TxtAttrs<IrohAttr> {
self.into()
}
#[cfg(not(wasm_browser))]
pub fn from_txt_lookup(
domain_name: String,
lookup: impl Iterator<Item = crate::dns::TxtRecordData>,
) -> Result<Self, ParseError> {
let attrs = TxtAttrs::from_txt_lookup(domain_name, lookup)?;
Ok(Self::from(attrs))
}
pub fn from_pkarr_signed_packet(packet: &pkarr::SignedPacket) -> Result<Self, ParseError> {
let attrs = TxtAttrs::from_pkarr_signed_packet(packet)?;
Ok(attrs.into())
}
pub fn to_pkarr_signed_packet(
&self,
secret_key: &SecretKey,
ttl: u32,
) -> Result<pkarr::SignedPacket, EncodingError> {
self.to_attrs().to_pkarr_signed_packet(secret_key, ttl)
}
pub fn to_txt_strings(&self) -> Vec<String> {
self.to_attrs().to_txt_strings().collect()
}
}
#[allow(missing_docs)]
#[stack_error(derive, add_meta, from_sources)]
#[non_exhaustive]
pub enum ParseError {
#[error("Expected format `key=value`, received `{s}`")]
UnexpectedFormat { s: String },
#[error("Could not convert key to Attr")]
AttrFromString { key: String },
#[error("Expected 2 labels, received {num_labels}")]
NumLabels { num_labels: usize },
#[error("Could not parse labels")]
Utf8 {
#[error(std_err)]
source: Utf8Error,
},
#[error("Record is not an `iroh` record, expected `_iroh`, got `{label}`")]
NotAnIrohRecord { label: String },
#[error(transparent)]
DecodingError { source: DecodingError },
}
impl std::ops::Deref for EndpointInfo {
type Target = EndpointData;
fn deref(&self) -> &Self::Target {
&self.data
}
}
impl std::ops::DerefMut for EndpointInfo {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.data
}
}
#[cfg(not(wasm_browser))]
fn endpoint_id_from_txt_name(name: &str) -> Result<EndpointId, ParseError> {
let num_labels = name.split(".").count();
if num_labels < 2 {
return Err(e!(ParseError::NumLabels { num_labels }));
}
let mut labels = name.split(".");
let label = labels.next().expect("checked above");
if label != IROH_TXT_NAME {
return Err(e!(ParseError::NotAnIrohRecord {
label: label.to_string()
}));
}
let label = labels.next().expect("checked above");
let endpoint_id = EndpointId::from_z32(label)?;
Ok(endpoint_id)
}
#[derive(
Debug, strum::Display, strum::AsRefStr, strum::EnumString, Hash, Eq, PartialEq, Ord, PartialOrd,
)]
#[strum(serialize_all = "kebab-case")]
pub(crate) enum IrohAttr {
Relay,
Addr,
UserData,
}
#[derive(Debug)]
pub(crate) struct TxtAttrs<T> {
endpoint_id: EndpointId,
attrs: BTreeMap<T, Vec<String>>,
}
impl From<&EndpointInfo> for TxtAttrs<IrohAttr> {
fn from(info: &EndpointInfo) -> Self {
let mut attrs = vec![];
for addr in &info.data.addrs {
match addr {
TransportAddr::Relay(relay_url) => {
attrs.push((IrohAttr::Relay, relay_url.to_string()))
}
TransportAddr::Ip(addr) => attrs.push((IrohAttr::Addr, addr.to_string())),
_ => {}
}
}
if let Some(user_data) = &info.data.user_data {
attrs.push((IrohAttr::UserData, user_data.to_string()));
}
Self::from_parts(info.endpoint_id, attrs.into_iter())
}
}
impl<T: FromStr + Display + Hash + Ord> TxtAttrs<T> {
pub(crate) fn from_parts(
endpoint_id: EndpointId,
pairs: impl Iterator<Item = (T, String)>,
) -> Self {
let mut attrs: BTreeMap<T, Vec<String>> = BTreeMap::new();
for (k, v) in pairs {
attrs.entry(k).or_default().push(v);
}
Self { attrs, endpoint_id }
}
pub(crate) fn from_strings(
endpoint_id: EndpointId,
strings: impl Iterator<Item = String>,
) -> Result<Self, ParseError> {
let mut attrs: BTreeMap<T, Vec<String>> = BTreeMap::new();
for s in strings {
let mut parts = s.split('=');
let (Some(key), Some(value)) = (parts.next(), parts.next()) else {
return Err(e!(ParseError::UnexpectedFormat { s }));
};
let attr = T::from_str(key).map_err(|_| {
e!(ParseError::AttrFromString {
key: key.to_string()
})
})?;
attrs.entry(attr).or_default().push(value.to_string());
}
Ok(Self { attrs, endpoint_id })
}
pub(crate) fn attrs(&self) -> &BTreeMap<T, Vec<String>> {
&self.attrs
}
pub(crate) fn endpoint_id(&self) -> EndpointId {
self.endpoint_id
}
pub(crate) fn from_pkarr_signed_packet(
packet: &pkarr::SignedPacket,
) -> Result<Self, ParseError> {
use pkarr::dns::{
rdata::RData,
{self},
};
let pubkey = packet.public_key();
let pubkey_z32 = pubkey.to_z32();
let endpoint_id =
EndpointId::from_bytes(&pubkey.verifying_key().to_bytes()).expect("valid key");
let zone = dns::Name::new(&pubkey_z32).expect("z32 encoding is valid");
let txt_data = packet
.all_resource_records()
.filter_map(|rr| match &rr.rdata {
RData::TXT(txt) => match rr.name.without(&zone) {
Some(name) if name.to_string() == IROH_TXT_NAME => Some(txt),
Some(_) | None => None,
},
_ => None,
});
let txt_strs = txt_data.filter_map(|s| String::try_from(s.clone()).ok());
Self::from_strings(endpoint_id, txt_strs)
}
#[cfg(not(wasm_browser))]
pub(crate) fn from_txt_lookup(
name: String,
lookup: impl Iterator<Item = crate::dns::TxtRecordData>,
) -> Result<Self, ParseError> {
let queried_endpoint_id = endpoint_id_from_txt_name(&name)?;
let strings = lookup.map(|record| record.to_string());
Self::from_strings(queried_endpoint_id, strings)
}
fn to_txt_strings(&self) -> impl Iterator<Item = String> + '_ {
self.attrs
.iter()
.flat_map(move |(k, vs)| vs.iter().map(move |v| format!("{k}={v}")))
}
pub(crate) fn to_pkarr_signed_packet(
&self,
secret_key: &SecretKey,
ttl: u32,
) -> Result<pkarr::SignedPacket, EncodingError> {
use pkarr::dns::{self, rdata};
let keypair = pkarr::Keypair::from_secret_key(&secret_key.to_bytes());
let name = dns::Name::new(IROH_TXT_NAME).expect("constant");
let mut builder = pkarr::SignedPacket::builder();
for s in self.to_txt_strings() {
let mut txt = rdata::TXT::new();
txt.add_string(&s)
.map_err(|err| e!(EncodingError::InvalidTxtEntry, err))?;
builder = builder.txt(name.clone(), txt.into_owned(), ttl);
}
let signed_packet = builder
.build(&keypair)
.map_err(|err| e!(EncodingError::FailedBuildingPacket, err))?;
Ok(signed_packet)
}
}
#[cfg(not(wasm_browser))]
pub(crate) fn ensure_iroh_txt_label(name: String) -> String {
let mut parts = name.split(".");
if parts.next() == Some(IROH_TXT_NAME) {
name
} else {
format!("{IROH_TXT_NAME}.{name}")
}
}
#[cfg(not(wasm_browser))]
pub(crate) fn endpoint_domain(endpoint_id: &EndpointId, origin: &str) -> String {
format!("{}.{}", EndpointId::to_z32(endpoint_id), origin)
}
#[cfg(test)]
mod tests {
use std::{collections::BTreeSet, str::FromStr, sync::Arc};
use hickory_resolver::{
Name,
lookup::Lookup,
proto::{
op::Query,
rr::{
RData, Record, RecordType,
rdata::{A, TXT},
},
},
};
use iroh_base::{EndpointId, SecretKey, TransportAddr};
use n0_error::{Result, StdResultExt};
use super::{EndpointData, EndpointIdExt, EndpointInfo};
use crate::dns::TxtRecordData;
#[test]
fn txt_attr_roundtrip() {
let endpoint_data = EndpointData::new([
TransportAddr::Relay("https://example.com".parse().unwrap()),
TransportAddr::Ip("127.0.0.1:1234".parse().unwrap()),
])
.with_user_data(Some("foobar".parse().unwrap()));
let endpoint_id = "vpnk377obfvzlipnsfbqba7ywkkenc4xlpmovt5tsfujoa75zqia"
.parse()
.unwrap();
let expected = EndpointInfo::from_parts(endpoint_id, endpoint_data);
let attrs = expected.to_attrs();
let actual = EndpointInfo::from(&attrs);
assert_eq!(expected, actual);
}
#[test]
fn signed_packet_roundtrip() {
let secret_key =
SecretKey::from_str("vpnk377obfvzlipnsfbqba7ywkkenc4xlpmovt5tsfujoa75zqia").unwrap();
let endpoint_data = EndpointData::new([
TransportAddr::Relay("https://example.com".parse().unwrap()),
TransportAddr::Ip("127.0.0.1:1234".parse().unwrap()),
])
.with_user_data(Some("foobar".parse().unwrap()));
let expected = EndpointInfo::from_parts(secret_key.public(), endpoint_data);
let packet = expected.to_pkarr_signed_packet(&secret_key, 30).unwrap();
let actual = EndpointInfo::from_pkarr_signed_packet(&packet).unwrap();
assert_eq!(expected, actual);
}
#[test]
fn test_from_hickory_lookup() -> Result {
let name = Name::from_utf8(
"_iroh.dgjpkxyn3zyrk3zfads5duwdgbqpkwbjxfj4yt7rezidr3fijccy.dns.iroh.link.",
)
.std_context("dns name")?;
let query = Query::query(name.clone(), RecordType::TXT);
let records = [
Record::from_rdata(
name.clone(),
30,
RData::TXT(TXT::new(vec!["addr=192.168.96.145:60165".to_string()])),
),
Record::from_rdata(
name.clone(),
30,
RData::TXT(TXT::new(vec!["addr=213.208.157.87:60165".to_string()])),
),
Record::from_rdata(name.clone(), 30, RData::A(A::new(127, 0, 0, 1))),
Record::from_rdata(
Name::from_utf8(format!(
"_iroh.{}.dns.iroh.link.",
EndpointId::from_str(
"a55f26132e5e43de834d534332f66a20d480c3e50a13a312a071adea6569981e"
)?
.to_z32()
))
.std_context("name")?,
30,
RData::TXT(TXT::new(vec![
"relay=https://euw1-1.relay.iroh.network./".to_string(),
])),
),
Record::from_rdata(
Name::from_utf8("dns.iroh.link.").std_context("name")?,
30,
RData::TXT(TXT::new(vec![
"relay=https://euw1-1.relay.iroh.network./".to_string(),
])),
),
Record::from_rdata(
name.clone(),
30,
RData::TXT(TXT::new(vec![
"relay=https://euw1-1.relay.iroh.network./".to_string(),
])),
),
];
let lookup = Lookup::new_with_max_ttl(query, Arc::new(records));
let lookup = hickory_resolver::lookup::TxtLookup::from(lookup);
let lookup = lookup
.into_iter()
.map(|txt| TxtRecordData::from_iter(txt.iter().cloned()));
let endpoint_info = EndpointInfo::from_txt_lookup(name.to_string(), lookup)?;
let expected_endpoint_info = EndpointInfo::new(EndpointId::from_str(
"1992d53c02cdc04566e5c0edb1ce83305cd550297953a047a445ea3264b54b18",
)?)
.with_relay_url(Some("https://euw1-1.relay.iroh.network./".parse()?))
.with_ip_addrs(BTreeSet::from([
"192.168.96.145:60165".parse().unwrap(),
"213.208.157.87:60165".parse().unwrap(),
]));
assert_eq!(endpoint_info, expected_endpoint_info);
Ok(())
}
}