Skip to content

Commit ac78cf2

Browse files
Frandomatheus23
andauthored
feat(iroh): publish and resolve user-defined data in discovery (#3176)
## Description **Based on #3175** Adds support for publishing and resolving a string of user-defined data through Iroh's discovery services. This gives applications a way to add additional information to the records published and resolved through discovery, in addition to the addressing information (relay URL and direct addresses). Iroh itself does not parse or use this user-defined data string in any way. All existing discovery services are updated to support the user data. All DNS-based discovery services encode the user-defined data in a TXT record, in the same way as the DNS/pkarr services already encode the relay URL and direct addresses. Therefore, the length of the `user-data=<user-data>` string is limited to 255 bytes, which is the max length for a single character string in DNS messages. Thus, the length of the actual user-defined data string is limited to 245 bytes (255 minux `user-data=`). This limit is enforced when constructing a `UserData` (which is just a wrapper around a `String` otherwise). The local swarm discovery uses `swarm-discovery` under the hood, for which I added support for TXT records in rkuhn/swarm-discovery#12. This was released as `swarm-discovery@0.3.0-alpha.2`. ## Breaking Changes <!-- Optional, if there are any breaking changes document them, including how to migrate older code. --> ## Notes & open questions Note that currently the `DiscoveryItem` which contains the user-defined data is only accessible when directly calling the methods on the `Discovery` trait. For discoveries initiated by the endpoint, both active from `Endpoint::connect` and passive from the magicsock's subscription to `Discovery::subscribe`, the `DiscoveryItem`s are currently not exposed in any way, thus there's no way for an app to access the user data of nodes discovered by the endpoint. However, with #3181, there will be a way to watch for all `DiscoveryItem`s found by the endpoint. ## Change checklist - [x] Self-review. - [x] Documentation updates following the [style guide](https://rust-lang.github.io/rfcs/1574-more-api-documentation-conventions.html#appendix-a-full-conventions-text), if relevant. - [x] Tests if relevant. - [x] All breaking changes documented. --------- Co-authored-by: Philipp Krüger <philipp.krueger1@gmail.com>
1 parent 31efead commit ac78cf2

File tree

8 files changed

+244
-28
lines changed

8 files changed

+244
-28
lines changed

iroh-dns-server/examples/publish.rs

+23-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
use std::str::FromStr;
1+
use std::{net::SocketAddr, str::FromStr};
22

33
use anyhow::{bail, Result};
44
use clap::{Parser, ValueEnum};
55
use iroh::{
66
discovery::{
77
dns::{N0_DNS_NODE_ORIGIN_PROD, N0_DNS_NODE_ORIGIN_STAGING},
88
pkarr::{PkarrRelayClient, N0_DNS_PKARR_RELAY_PROD, N0_DNS_PKARR_RELAY_STAGING},
9+
UserData,
910
},
1011
dns::node_info::{NodeIdExt, NodeInfo, IROH_TXT_NAME},
1112
NodeId, SecretKey,
@@ -39,7 +40,14 @@ struct Cli {
3940
#[clap(long, conflicts_with = "env")]
4041
pkarr_relay: Option<Url>,
4142
/// Home relay server to publish for this node
42-
relay_url: Url,
43+
#[clap(short, long)]
44+
relay_url: Option<Url>,
45+
/// Direct addresses to publish for this node
46+
#[clap(short, long)]
47+
addr: Vec<SocketAddr>,
48+
/// User data to publish for this node
49+
#[clap(short, long)]
50+
user_data: Option<UserData>,
4351
/// Create a new node secret if IROH_SECRET is unset. Only for development / debugging.
4452
#[clap(short, long)]
4553
create: bool,
@@ -72,12 +80,23 @@ async fn main() -> Result<()> {
7280
};
7381

7482
println!("announce {node_id}:");
75-
println!(" relay={}", args.relay_url);
83+
if let Some(relay_url) = &args.relay_url {
84+
println!(" relay={relay_url}");
85+
}
86+
for addr in &args.addr {
87+
println!(" addr={addr}");
88+
}
89+
if let Some(user_data) = &args.user_data {
90+
println!(" user-data={user_data}");
91+
}
7692
println!();
7793
println!("publish to {pkarr_relay} ...");
7894

7995
let pkarr = PkarrRelayClient::new(pkarr_relay);
80-
let node_info = NodeInfo::new(node_id).with_relay_url(Some(args.relay_url.into()));
96+
let node_info = NodeInfo::new(node_id)
97+
.with_relay_url(args.relay_url.map(Into::into))
98+
.with_direct_addresses(args.addr.into_iter().collect())
99+
.with_user_data(args.user_data);
81100
let signed_packet = node_info.to_pkarr_signed_packet(&secret_key, 30)?;
82101
pkarr.publish(&signed_packet).await?;
83102

iroh-dns-server/examples/resolve.rs

+3
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,8 @@ async fn main() -> anyhow::Result<()> {
5757
for addr in resolved.direct_addresses() {
5858
println!(" addr={addr}")
5959
}
60+
if let Some(user_data) = resolved.user_data() {
61+
println!(" user-data={user_data}")
62+
}
6063
Ok(())
6164
}

iroh-relay/src/dns/node_info.rs

+107-4
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
3535
use std::{
3636
collections::{BTreeMap, BTreeSet},
37-
fmt::Display,
37+
fmt::{self, Display},
3838
hash::Hash,
3939
net::SocketAddr,
4040
str::FromStr,
@@ -80,7 +80,9 @@ impl NodeIdExt for NodeId {
8080

8181
/// Data about a node that may be published to and resolved from discovery services.
8282
///
83-
/// This includes an optional [`RelayUrl`] and a set of direct addresses.
83+
/// This includes an optional [`RelayUrl`], a set of direct addresses, and the optional
84+
/// [`UserData`], a string that can be set by applications and is not parsed or used by iroh
85+
/// itself.
8486
///
8587
/// This struct does not include the node's [`NodeId`], only the data *about* a certain
8688
/// node. See [`NodeInfo`] for a struct that contains a [`NodeId`] with associated [`NodeData`].
@@ -90,6 +92,8 @@ pub struct NodeData {
9092
relay_url: Option<RelayUrl>,
9193
/// Direct addresses where this node can be reached.
9294
direct_addresses: BTreeSet<SocketAddr>,
95+
/// Optional user-defined [`UserData`] for this node.
96+
user_data: Option<UserData>,
9397
}
9498

9599
impl NodeData {
@@ -98,6 +102,7 @@ impl NodeData {
98102
Self {
99103
relay_url,
100104
direct_addresses,
105+
user_data: None,
101106
}
102107
}
103108

@@ -113,11 +118,22 @@ impl NodeData {
113118
self
114119
}
115120

121+
/// Sets the user-defined data and returns the updated node data.
122+
pub fn with_user_data(mut self, user_data: Option<UserData>) -> Self {
123+
self.user_data = user_data;
124+
self
125+
}
126+
116127
/// Returns the relay URL of the node.
117128
pub fn relay_url(&self) -> Option<&RelayUrl> {
118129
self.relay_url.as_ref()
119130
}
120131

132+
/// Returns the optional user-defined data of the node.
133+
pub fn user_data(&self) -> Option<&UserData> {
134+
self.user_data.as_ref()
135+
}
136+
121137
/// Returns the direct addresses of the node.
122138
pub fn direct_addresses(&self) -> &BTreeSet<SocketAddr> {
123139
&self.direct_addresses
@@ -137,17 +153,84 @@ impl NodeData {
137153
pub fn set_relay_url(&mut self, relay_url: Option<RelayUrl>) {
138154
self.relay_url = relay_url
139155
}
156+
157+
/// Sets the user-defined data of the node data.
158+
pub fn set_user_data(&mut self, user_data: Option<UserData>) {
159+
self.user_data = user_data;
160+
}
140161
}
141162

142163
impl From<NodeAddr> for NodeData {
143164
fn from(node_addr: NodeAddr) -> Self {
144165
Self {
145166
relay_url: node_addr.relay_url,
146167
direct_addresses: node_addr.direct_addresses,
168+
user_data: None,
169+
}
170+
}
171+
}
172+
173+
// User-defined data that can be published and resolved through node discovery.
174+
///
175+
/// Under the hood this is a UTF-8 String is no longer than [`UserData::MAX_LENGTH`] bytes.
176+
///
177+
/// Iroh does not keep track of or examine the user-defined data.
178+
///
179+
/// `UserData` implements [`FromStr`] and [`TryFrom<String>`], so you can
180+
/// convert `&str` and `String` into `UserData` easily.
181+
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
182+
pub struct UserData(String);
183+
184+
impl UserData {
185+
/// The max byte length allowed for user-defined data.
186+
///
187+
/// In DNS discovery services, the user-defined data is stored in a TXT record character string,
188+
/// which has a max length of 255 bytes. We need to subtract the `user-data=` prefix,
189+
/// which leaves 245 bytes for the actual user-defined data.
190+
pub const MAX_LENGTH: usize = 245;
191+
}
192+
193+
/// Error returned when an input value is too long for [`UserData`].
194+
#[derive(Debug, thiserror::Error)]
195+
#[error("User-defined data exceeds max length")]
196+
pub struct MaxLengthExceededError;
197+
198+
impl TryFrom<String> for UserData {
199+
type Error = MaxLengthExceededError;
200+
201+
fn try_from(value: String) -> Result<Self, Self::Error> {
202+
if value.len() > Self::MAX_LENGTH {
203+
Err(MaxLengthExceededError)
204+
} else {
205+
Ok(Self(value))
206+
}
207+
}
208+
}
209+
210+
impl FromStr for UserData {
211+
type Err = MaxLengthExceededError;
212+
213+
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
214+
if s.len() > Self::MAX_LENGTH {
215+
Err(MaxLengthExceededError)
216+
} else {
217+
Ok(Self(s.to_string()))
147218
}
148219
}
149220
}
150221

222+
impl fmt::Display for UserData {
223+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224+
write!(f, "{}", self.0)
225+
}
226+
}
227+
228+
impl AsRef<str> for UserData {
229+
fn as_ref(&self) -> &str {
230+
&self.0
231+
}
232+
}
233+
151234
/// Information about a node that may be published to and resolved from discovery services.
152235
///
153236
/// This struct couples a [`NodeId`] with its associated [`NodeData`].
@@ -181,9 +264,16 @@ impl From<&TxtAttrs<IrohAttr>> for NodeInfo {
181264
.flatten()
182265
.filter_map(|s| SocketAddr::from_str(s).ok())
183266
.collect();
267+
let user_data = attrs
268+
.get(&IrohAttr::UserData)
269+
.into_iter()
270+
.flatten()
271+
.next()
272+
.and_then(|s| UserData::from_str(s).ok());
184273
let data = NodeData {
185274
relay_url: relay_url.map(Into::into),
186275
direct_addresses,
276+
user_data,
187277
};
188278
Self { node_id, data }
189279
}
@@ -226,6 +316,12 @@ impl NodeInfo {
226316
self
227317
}
228318

319+
/// Sets the user-defined data and returns the updated node info.
320+
pub fn with_user_data(mut self, user_data: Option<UserData>) -> Self {
321+
self.data = self.data.with_user_data(user_data);
322+
self
323+
}
324+
229325
/// Converts into a [`NodeAddr`] by cloning the needed fields.
230326
pub fn to_node_addr(&self) -> NodeAddr {
231327
NodeAddr {
@@ -321,6 +417,8 @@ pub(super) enum IrohAttr {
321417
Relay,
322418
/// Direct address.
323419
Addr,
420+
/// User-defined data
421+
UserData,
324422
}
325423

326424
/// Attributes parsed from [`IROH_TXT_NAME`] TXT records.
@@ -343,6 +441,9 @@ impl From<&NodeInfo> for TxtAttrs<IrohAttr> {
343441
for addr in &info.data.direct_addresses {
344442
attrs.push((IrohAttr::Addr, addr.to_string()));
345443
}
444+
if let Some(user_data) = &info.data.user_data {
445+
attrs.push((IrohAttr::UserData, user_data.to_string()));
446+
}
346447
Self::from_parts(info.node_id, attrs.into_iter())
347448
}
348449
}
@@ -552,7 +653,8 @@ mod tests {
552653
let node_data = NodeData::new(
553654
Some("https://example.com".parse().unwrap()),
554655
["127.0.0.1:1234".parse().unwrap()].into_iter().collect(),
555-
);
656+
)
657+
.with_user_data(Some("foobar".parse().unwrap()));
556658
let node_id = "vpnk377obfvzlipnsfbqba7ywkkenc4xlpmovt5tsfujoa75zqia"
557659
.parse()
558660
.unwrap();
@@ -569,7 +671,8 @@ mod tests {
569671
let node_data = NodeData::new(
570672
Some("https://example.com".parse().unwrap()),
571673
["127.0.0.1:1234".parse().unwrap()].into_iter().collect(),
572-
);
674+
)
675+
.with_user_data(Some("foobar".parse().unwrap()));
573676
let expected = NodeInfo::from_parts(secret_key.public(), node_data);
574677
let packet = expected.to_pkarr_signed_packet(&secret_key, 30).unwrap();
575678
let actual = NodeInfo::from_pkarr_signed_packet(&packet).unwrap();

iroh/src/discovery.rs

+9-5
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ use std::sync::Arc;
109109

110110
use anyhow::{anyhow, ensure, Result};
111111
use iroh_base::{NodeAddr, NodeId};
112-
pub use iroh_relay::dns::node_info::{NodeData, NodeInfo};
112+
pub use iroh_relay::dns::node_info::{NodeData, NodeInfo, UserData};
113113
use n0_future::{
114114
stream::{Boxed as BoxStream, StreamExt},
115115
task::{self, AbortOnDropHandle},
@@ -943,7 +943,7 @@ mod tests {
943943
mod test_dns_pkarr {
944944
use anyhow::Result;
945945
use iroh_base::{NodeAddr, SecretKey};
946-
use iroh_relay::RelayMap;
946+
use iroh_relay::{dns::node_info::UserData, RelayMap};
947947
use n0_future::time::Duration;
948948
use tokio_util::task::AbortOnDropHandle;
949949
use tracing_test::traced_test;
@@ -996,20 +996,24 @@ mod test_dns_pkarr {
996996

997997
let resolver = DnsResolver::with_nameserver(dns_pkarr_server.nameserver);
998998
let publisher = PkarrPublisher::new(secret_key, dns_pkarr_server.pkarr_url.clone());
999-
let data = NodeData::new(relay_url.clone(), Default::default());
999+
let user_data: UserData = "foobar".parse().unwrap();
1000+
let data = NodeData::new(relay_url.clone(), Default::default())
1001+
.with_user_data(Some(user_data.clone()));
10001002
// does not block, update happens in background task
10011003
publisher.update_node_data(&data);
10021004
// wait until our shared state received the update from pkarr publishing
10031005
dns_pkarr_server.on_node(&node_id, PUBLISH_TIMEOUT).await?;
10041006
let resolved = resolver.lookup_node_by_id(&node_id, &origin).await?;
1007+
println!("resolved {resolved:?}");
10051008

1006-
let expected = NodeAddr {
1009+
let expected_addr = NodeAddr {
10071010
node_id,
10081011
relay_url,
10091012
direct_addresses: Default::default(),
10101013
};
10111014

1012-
assert_eq!(resolved.to_node_addr(), expected);
1015+
assert_eq!(resolved.to_node_addr(), expected_addr);
1016+
assert_eq!(resolved.user_data(), Some(&user_data));
10131017
Ok(())
10141018
}
10151019

0 commit comments

Comments
 (0)