Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
770fff0
feat: Add an explicit optional label field to the endpoint
okdistribute Mar 11, 2026
ab50010
add to examples
okdistribute Mar 11, 2026
ea89c49
fix lint
okdistribute Mar 11, 2026
ce6d3e5
Update src/client.rs
okdistribute Mar 11, 2026
b014f25
Update src/protocol.rs
okdistribute Mar 11, 2026
8526f29
Update src/protocol.rs
okdistribute Mar 11, 2026
50a1649
Update src/protocol.rs
okdistribute Mar 11, 2026
5447792
fmt
okdistribute Mar 11, 2026
f141e4b
remove chatgpt rec
okdistribute Mar 11, 2026
2e99afa
add client labels back
okdistribute Mar 11, 2026
518c5da
fmt
okdistribute Mar 11, 2026
727e765
fix bad copilot rec
okdistribute Mar 11, 2026
6369099
use name instead of label for historical reasons
okdistribute Mar 11, 2026
e9f7850
some fixups
dignifiedquire Mar 16, 2026
8adcee9
fixup
dignifiedquire Mar 16, 2026
1e72e5d
refactor(client): switch name -> label, document & enforce requirements
b5 Mar 23, 2026
c485415
refactor: use error enum & constants for label length
b5 Mar 23, 2026
a6abd47
refactor(Auth): use constructor to build Auth
b5 Mar 23, 2026
b1e26cb
docs(Client): match label length
b5 Mar 23, 2026
d5bfb79
refactor: unbreak the API
b5 Mar 23, 2026
3634621
fix: auth before setting label
b5 Mar 23, 2026
8745af8
refactor: use name instead of label
b5 Apr 9, 2026
2be4508
refactor!: rpc protocol: LabelEndpoint -> NameEndpoint
b5 Apr 9, 2026
31ca9ba
more label -> name renaming
b5 Apr 9, 2026
eaa08f8
cleanups
b5 Apr 9, 2026
f8f0a54
refactor!: LabelEndpoint -> NameEndpoint
b5 Apr 10, 2026
e87ddf8
apply more CR
dignifiedquire Apr 11, 2026
41f05be
clippy
dignifiedquire Apr 11, 2026
94e448d
fixup validation
dignifiedquire Apr 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ getrandom = { version = "0.3.2", features = ["wasm_js"] }
built = { version = "0.7", features = ["cargo-lock"] }

[dev-dependencies]
rand_chacha = "0.9"
temp_env_vars = "0.2.1"
tokio = { version = "1.45", features = ["macros", "rt", "rt-multi-thread"] }

Expand Down
9 changes: 9 additions & 0 deletions examples/net_diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ use iroh_services::{

#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();

// 1. Create an endpoint that will both dial iroh-services and accept incoming
// requests from the iroh-services service via a ClientHost.
let endpoint = Endpoint::bind(presets::N0).await?;
Expand All @@ -26,9 +28,16 @@ async fn main() -> Result<()> {
// EndpointID. Normally we'd pass it straight to the client builder.
let secret = ApiSecret::from_env_var(API_SECRET_ENV_VAR_NAME)?;

// optional: name the endpoint. Here we generate a name from the endpoint id
// to keep name unique. in your app this would be used to connect with
// something like a userId or machine name
let id = endpoint.id().to_string();
let name = format!("net-diagnostics-example-{}", &id[..8]);

// 3. Build a Client that dials iroh-services (as in all other examples).
let client = Client::builder(&endpoint)
.api_secret(secret.clone())?
.name(name)?
.build()
.await?;

Expand Down
184 changes: 175 additions & 9 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use crate::protocol::PutNetworkDiagnostics;
use crate::{
api_secret::ApiSecret,
caps::Caps,
protocol::{ALPN, Auth, IrohServicesClient, Ping, Pong, PutMetrics, RemoteError},
protocol::{ALPN, Auth, IrohServicesClient, NameEndpoint, Ping, Pong, PutMetrics, RemoteError},
};

/// Client is the main handle for interacting with iroh-services. It communicates with
Expand Down Expand Up @@ -63,6 +63,7 @@ pub struct ClientBuilder {
cap_expiry: Duration,
cap: Option<Rcan<Caps>>,
endpoint: Endpoint,
name: Option<String>,
metrics_interval: Option<Duration>,
remote: Option<EndpointAddr>,
registry: Registry,
Expand All @@ -80,6 +81,7 @@ impl ClientBuilder {
cap: None,
cap_expiry: DEFAULT_CAP_EXPIRY,
endpoint: endpoint.clone(),
name: None,
metrics_interval: Some(Duration::from_secs(60)),
remote: None,
registry,
Expand Down Expand Up @@ -108,6 +110,27 @@ impl ClientBuilder {
self
}

/// Set an optional human-readable name for the endpoint the client is
/// constructed with, making metrics from this endpoint easier to identify.
/// This is often used for associating with other services in your app,
/// like a database user id, machine name, permanent username, etc.
///
/// When this builder method is called, the provided name is sent after the
/// client initially authenticates the endpoint server-side.
/// Errors will not interrupt client construction, instead producing a
/// warn-level log. For explicit error handling, use [`Client::set_name`].
///
/// names can be any UTF-8 string, with a min length of 2 bytes, and
/// maximum length of 128 bytes. **name uniqueness is not enforced
/// server-side**, which means using the same name for different endpoints
/// will not produce an error
pub fn name(mut self, name: impl Into<String>) -> Result<Self> {
let name = name.into();
validate_name(&name).map_err(BuildError::InvalidName)?;
self.name = Some(name);
Ok(self)
}

/// Check IROH_SERVICES_API_SECRET environment variable for a valid API secret
pub fn api_secret_from_env(self) -> Result<Self> {
let ticket = ApiSecret::from_env_var(API_SECRET_ENV_VAR_NAME)?;
Expand Down Expand Up @@ -188,23 +211,24 @@ impl ClientBuilder {
let capabilities = self.cap.ok_or(BuildError::MissingCapability)?;

let conn = IrohLazyRemoteConnection::new(self.endpoint.clone(), remote, ALPN.to_vec());
let client = IrohServicesClient::boxed(conn);
let irpc_client = IrohServicesClient::boxed(conn);

let (tx, rx) = tokio::sync::mpsc::channel(1);
let metrics_task = AbortOnDropHandle::new(n0_future::task::spawn(
let actor_task = AbortOnDropHandle::new(n0_future::task::spawn(
ClientActor {
capabilities,
client,
client: irpc_client,
name: self.name.clone(),
session_id: Uuid::new_v4(),
authorized: false,
}
.run(self.registry, self.metrics_interval, rx),
.run(self.name, self.registry, self.metrics_interval, rx),
));

Ok(Client {
endpoint: self.endpoint,
message_channel: tx,
_actor_task: Arc::new(metrics_task),
_actor_task: Arc::new(actor_task),
})
}
}
Expand All @@ -223,6 +247,8 @@ pub enum BuildError {
Rpc(irpc::Error),
#[error("Connection error: {0}")]
Connect(ConnectError),
#[error("Invalid endpoint name: {0}")]
InvalidName(#[from] ValidateNameError),
}

impl From<irpc::Error> for BuildError {
Expand All @@ -241,8 +267,34 @@ impl From<irpc::Error> for BuildError {
}
}

/// Minimum length in bytes for an endpoint name.
pub const CLIENT_NAME_MIN_LENGTH: usize = 2;
Comment thread
dignifiedquire marked this conversation as resolved.
/// Maximum length in bytes for an endpoint name.
pub const CLIENT_NAME_MAX_LENGTH: usize = 128;

/// Error returned when an endpoint name fails validation.
#[derive(Debug, thiserror::Error)]
pub enum ValidateNameError {
Comment thread
dignifiedquire marked this conversation as resolved.
#[error("Name is too long (must be no more than {CLIENT_NAME_MAX_LENGTH} characters).")]
TooLong,
#[error("Name is too short (must be at least {CLIENT_NAME_MIN_LENGTH} characters).")]
TooShort,
}

fn validate_name(name: &str) -> Result<(), ValidateNameError> {
if name.len() < CLIENT_NAME_MIN_LENGTH {
Err(ValidateNameError::TooShort)
} else if name.len() > CLIENT_NAME_MAX_LENGTH {
Err(ValidateNameError::TooLong)
} else {
Ok(())
}
}

#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Invalid endpoint name: {0}")]
InvalidName(#[from] ValidateNameError),
#[error("Remote error: {0}")]
Remote(#[from] RemoteError),
#[error("Connection error: {0}")]
Expand All @@ -256,6 +308,26 @@ impl Client {
ClientBuilder::new(endpoint)
}

/// Read the current endpoint name from the local client.
pub async fn name(&self) -> Result<Option<String>, Error> {
let (tx, rx) = oneshot::channel();
self.message_channel
.send(ClientActorMessage::ReadName { done: tx })
.await
.map_err(|_| Error::Other(anyhow!("sending name read request")))?;

rx.await
.map_err(|e| Error::Other(anyhow!("response on internal channel: {:?}", e)))
}

/// Name the active endpoint cloud-side.
///
/// names can be any UTF-8 string, with a min length of 2 bytes, and
/// maximum length of 128 bytes. **name uniqueness is not enforced.**
pub async fn set_name(&self, name: impl Into<String>) -> Result<(), Error> {
set_name_inner(self.message_channel.clone(), name.into()).await
}

/// Pings the remote node.
pub async fn ping(&self) -> Result<Pong, Error> {
let (tx, rx) = oneshot::channel();
Expand Down Expand Up @@ -356,18 +428,27 @@ enum ClientActorMessage {
report: Box<DiagnosticsReport>,
done: oneshot::Sender<Result<(), Error>>,
},
ReadName {
done: oneshot::Sender<Option<String>>,
},
NameEndpoint {
name: String,
done: oneshot::Sender<Result<(), RemoteError>>,
},
}

struct ClientActor {
capabilities: Rcan<Caps>,
client: IrohServicesClient,
name: Option<String>,
session_id: Uuid,
authorized: bool,
}

impl ClientActor {
async fn run(
mut self,
initial_name: Option<String>,
registry: Registry,
interval: Option<Duration>,
mut inbox: tokio::sync::mpsc::Receiver<ClientActorMessage>,
Expand All @@ -376,6 +457,13 @@ impl ClientActor {
let mut encoder = Encoder::new(registry);
let mut metrics_timer = interval.map(|interval| n0_future::time::interval(interval));
trace!("starting client actor");

if let Some(name) = initial_name
&& let Err(err) = self.send_name_endpoint(name).await
{
warn!(err = %err, "failed setting endpoint name on startup");
}

loop {
trace!("client actor tick");
tokio::select! {
Expand Down Expand Up @@ -403,6 +491,17 @@ impl ClientActor {
warn!("failed to grant capability: {:#?}", err);
}
}
ClientActorMessage::ReadName{ done } => {
if let Err(err) = done.send(self.name.clone()) {
warn!("sending name value: {:#?}", err);
}
}
ClientActorMessage::NameEndpoint{ name, done } => {
let res = self.send_name_endpoint(name).await;
if let Err(err) = done.send(res) {
warn!("failed to name endpoint: {:#?}", err);
}
}
#[cfg(feature = "net_diagnostics")]
ClientActorMessage::PutNetworkDiagnostics{ report, done } => {
let res = self.put_network_diagnostics(*report).await;
Expand Down Expand Up @@ -458,6 +557,19 @@ impl ClientActor {
.map_err(|_| RemoteError::InternalServerError)
}

async fn send_name_endpoint(&mut self, name: String) -> Result<(), RemoteError> {
trace!("client sending name endpoint request");
self.auth().await?;

self.client
.rpc(NameEndpoint { name: name.clone() })
.await
.inspect_err(|e| debug!("name endpoint error: {e}"))
.map_err(|_| RemoteError::InternalServerError)??;
self.name = Some(name);
Ok(())
}

async fn send_metrics(&mut self, encoder: &mut Encoder) -> Result<(), RemoteError> {
trace!("client actor send metrics");
self.auth().await?;
Expand Down Expand Up @@ -508,6 +620,22 @@ impl ClientActor {
}
}

async fn set_name_inner(
message_channel: tokio::sync::mpsc::Sender<ClientActorMessage>,
name: String,
) -> Result<(), Error> {
validate_name(&name)?;
debug!(name_len = name.len(), "calling set name");
let (tx, rx) = oneshot::channel();
message_channel
.send(ClientActorMessage::NameEndpoint { name, done: tx })
.await
.map_err(|_| Error::Other(anyhow!("sending name endpoint request")))?;
rx.await
.map_err(|e| Error::Other(anyhow!("response on internal channel: {:?}", e)))?
.map_err(Error::Remote)
}

#[cfg(test)]
mod tests {
use iroh::{Endpoint, EndpointAddr, SecretKey};
Expand All @@ -517,14 +645,15 @@ mod tests {
Client,
api_secret::ApiSecret,
caps::{Cap, Caps},
client::API_SECRET_ENV_VAR_NAME,
client::{API_SECRET_ENV_VAR_NAME, BuildError, ValidateNameError},
};

#[tokio::test]
#[temp_env_vars]
async fn test_api_key_from_env() {
use rand::SeedableRng;
// construct
let mut rng = rand::rng();
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(0);
let shared_secret = SecretKey::generate(&mut rng);
let fake_endpoint_id = SecretKey::generate(&mut rng).public();
let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
Expand All @@ -551,7 +680,8 @@ mod tests {
/// panicking. Metrics sending itself is expected to fail.
#[tokio::test]
async fn test_no_metrics_interval() {
let mut rng = rand::rng();
use rand::SeedableRng;
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(1);
let shared_secret = SecretKey::generate(&mut rng);
let fake_endpoint_id = SecretKey::generate(&mut rng).public();
let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
Expand All @@ -569,4 +699,40 @@ mod tests {
let err = client.push_metrics().await;
assert!(err.is_err());
}

#[tokio::test]
async fn test_name() {
use rand::SeedableRng;
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(0);
let shared_secret = SecretKey::generate(&mut rng);
let fake_endpoint_id = SecretKey::generate(&mut rng).public();
let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);

let endpoint = Endpoint::empty_builder().bind().await.unwrap();

let builder = Client::builder(&endpoint)
.name("my-node 👋")
.unwrap()
.api_secret(api_secret)
.unwrap();

assert_eq!(builder.name, Some("my-node 👋".to_string()));

let Err(err) = Client::builder(&endpoint).name("a") else {
panic!("name should fail for strings under 2 bytes");
};
assert!(matches!(
err.downcast_ref::<BuildError>(),
Some(BuildError::InvalidName(ValidateNameError::TooShort))
));

let too_long_name = "👋".repeat(129);
let Err(err) = Client::builder(&endpoint).name(&too_long_name) else {
panic!("name should fail for strings over 128 bytes");
};
assert!(matches!(
err.downcast_ref::<BuildError>(),
Some(BuildError::InvalidName(ValidateNameError::TooLong))
));
}
}
9 changes: 9 additions & 0 deletions src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub enum IrohServicesProtocol {

#[rpc(tx=oneshot::Sender<RemoteResult<()>>)]
GrantCap(GrantCap),

#[rpc(tx=oneshot::Sender<RemoteResult<()>>)]
NameEndpoint(NameEndpoint),
}

/// Dedicated protocol for cloud-to-endpoint net diagnostics connections.
Expand Down Expand Up @@ -95,3 +98,9 @@ pub struct RunNetworkDiagnostics;
pub struct GrantCap {
pub cap: Rcan<Caps>,
}

/// Label the client endpoint cloud-side with a string identifier.
#[derive(Debug, Serialize, Deserialize)]
pub struct NameEndpoint {
pub name: String,
}
Loading