diff --git a/Cargo.lock b/Cargo.lock index 35345367..ad7939ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1775,6 +1775,7 @@ dependencies = [ "portmapper", "postcard", "rand", + "rand_chacha", "rcan", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index e928c4e6..347e5f6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/examples/net_diagnostics.rs b/examples/net_diagnostics.rs index 4b6b52ed..5f45025f 100644 --- a/examples/net_diagnostics.rs +++ b/examples/net_diagnostics.rs @@ -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?; @@ -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?; diff --git a/src/client.rs b/src/client.rs index b1fcc41a..38a8fc6e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -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 @@ -63,6 +63,7 @@ pub struct ClientBuilder { cap_expiry: Duration, cap: Option>, endpoint: Endpoint, + name: Option, metrics_interval: Option, remote: Option, registry: Registry, @@ -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, @@ -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) -> Result { + 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 { let ticket = ApiSecret::from_env_var(API_SECRET_ENV_VAR_NAME)?; @@ -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), }) } } @@ -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 for BuildError { @@ -241,8 +267,34 @@ impl From for BuildError { } } +/// Minimum length in bytes for an endpoint name. +pub const CLIENT_NAME_MIN_LENGTH: usize = 2; +/// 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 { + #[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}")] @@ -256,6 +308,26 @@ impl Client { ClientBuilder::new(endpoint) } + /// Read the current endpoint name from the local client. + pub async fn name(&self) -> Result, 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) -> Result<(), Error> { + set_name_inner(self.message_channel.clone(), name.into()).await + } + /// Pings the remote node. pub async fn ping(&self) -> Result { let (tx, rx) = oneshot::channel(); @@ -356,11 +428,19 @@ enum ClientActorMessage { report: Box, done: oneshot::Sender>, }, + ReadName { + done: oneshot::Sender>, + }, + NameEndpoint { + name: String, + done: oneshot::Sender>, + }, } struct ClientActor { capabilities: Rcan, client: IrohServicesClient, + name: Option, session_id: Uuid, authorized: bool, } @@ -368,6 +448,7 @@ struct ClientActor { impl ClientActor { async fn run( mut self, + initial_name: Option, registry: Registry, interval: Option, mut inbox: tokio::sync::mpsc::Receiver, @@ -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! { @@ -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; @@ -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?; @@ -508,6 +620,22 @@ impl ClientActor { } } +async fn set_name_inner( + message_channel: tokio::sync::mpsc::Sender, + 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}; @@ -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); @@ -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); @@ -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::(), + 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::(), + Some(BuildError::InvalidName(ValidateNameError::TooLong)) + )); + } } diff --git a/src/protocol.rs b/src/protocol.rs index fc62d119..40cbbf0e 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -27,6 +27,9 @@ pub enum IrohServicesProtocol { #[rpc(tx=oneshot::Sender>)] GrantCap(GrantCap), + + #[rpc(tx=oneshot::Sender>)] + NameEndpoint(NameEndpoint), } /// Dedicated protocol for cloud-to-endpoint net diagnostics connections. @@ -95,3 +98,9 @@ pub struct RunNetworkDiagnostics; pub struct GrantCap { pub cap: Rcan, } + +/// Label the client endpoint cloud-side with a string identifier. +#[derive(Debug, Serialize, Deserialize)] +pub struct NameEndpoint { + pub name: String, +}