From a45dedb30f8f210600e1b9c1d9a7ad68e0378ff4 Mon Sep 17 00:00:00 2001 From: b5 Date: Mon, 4 May 2026 09:36:38 -0400 Subject: [PATCH 1/4] feat(preset): relay preset, builder, and example --- Cargo.lock | 1 + Cargo.toml | 1 + examples/relays.rs | 47 ++++++++++++ src/api_secret.rs | 2 + src/caps.rs | 2 + src/client.rs | 7 +- src/lib.rs | 6 +- src/preset.rs | 186 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 examples/relays.rs create mode 100644 src/preset.rs diff --git a/Cargo.lock b/Cargo.lock index 81a410db..51f16de8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1716,6 +1716,7 @@ dependencies = [ "base64", "built", "bytes", + "data-encoding", "derive_more", "ed25519-dalek", "futures-buffered", diff --git a/Cargo.toml b/Cargo.toml index 83e1257d..4c84a8ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ rust-version = "1.91" [dependencies] anyhow = "1.0.95" +data-encoding = "2.9.0" derive_more = { version = "2.0.1", features = ["display", "from"] } ed25519-dalek = { version = "=3.0.0-pre.7" } irpc = { version = "0.15", default-features = false, features = ["derive", "stream", "spans"] } diff --git a/examples/relays.rs b/examples/relays.rs new file mode 100644 index 00000000..5eb84f6b --- /dev/null +++ b/examples/relays.rs @@ -0,0 +1,47 @@ +//! This example shows using custom relays provided by iroh services. +use iroh::{Endpoint, SecretKey}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // minimal preset that works with a free iroh services project. This will + // give a bandwidth bump when using the public relays, and surface your + // relay traffic on your project: + let _preset = iroh_services::preset().api_secret_from_env()?.build()?; + + // pro & enterprise projects have access to custom relays, which are set + // with the `relays` method on the builder: + let _preset = iroh_services::preset() + .relays([ + "https://us-east1.project_username.iroh.link", + "https://eu-west1.project_username.iroh.link", + "https://eu-central1.project_username.iroh.link", + ])? + .api_secret_from_env()? + .build()?; + + // if you are using a secret key from disk, or generally want control over + // the ID your endpoint uses, the secret key must be given to the preset + // before passing it to the endpoint (because the endpoint ID is the public + // half of the secret keypair). The access token that's created by the + // preset to access relays is scoped only to the given key. + // + // If no key is provided, a random one is generated and passed to the + // endpoint. + let secret_key = SecretKey::generate(); + let preset = iroh_services::preset() + .secret_key(secret_key) + .api_secret_from_env()? + .build()?; + + // once a preset is built, we'll pass it to the endpoint for binding. + // we clone the preset so we can reuse to get a client builder below + let endpoint = Endpoint::bind(preset.clone()).await?; + + // a client is not required to use + let client = preset.client_builder(&endpoint).build().await?; + + // we can also ping the service just to confirm everything is working + client.ping().await?; + + Ok(()) +} diff --git a/src/api_secret.rs b/src/api_secret.rs index e5e755e5..3f6e7ae5 100644 --- a/src/api_secret.rs +++ b/src/api_secret.rs @@ -10,6 +10,8 @@ use iroh::{EndpointAddr, EndpointId, SecretKey, TransportAddr}; use iroh_tickets::{ParseError, Ticket}; use serde::{Deserialize, Serialize}; +pub const API_SECRET_ENV_VAR_NAME: &str = "IROH_SERVICES_API_SECRET"; + /// The secret material used to connect your services.iroh.computer project. The /// value of these should be treated like any other API key: guard them carefully. #[derive(Debug, Clone)] diff --git a/src/caps.rs b/src/caps.rs index a55c2648..0b12e4ae 100644 --- a/src/caps.rs +++ b/src/caps.rs @@ -6,6 +6,8 @@ use n0_future::time::Duration; use rcan::{Capability, Expires, Rcan}; use serde::{Deserialize, Serialize}; +pub(crate) const DEFAULT_CAP_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24 * 30); // 1 month + macro_rules! cap_enum( ($enum:item) => { #[derive( diff --git a/src/client.rs b/src/client.rs index 44ef8ac2..5931d209 100644 --- a/src/client.rs +++ b/src/client.rs @@ -15,8 +15,8 @@ use tracing::{debug, trace, warn}; use uuid::Uuid; use crate::{ - api_secret::ApiSecret, - caps::Caps, + api_secret::{API_SECRET_ENV_VAR_NAME, ApiSecret}, + caps::{Caps, DEFAULT_CAP_EXPIRY}, net_diagnostics::{DiagnosticsReport, checks::run_diagnostics}, protocol::{ ALPN, Auth, IrohServicesClient, NameEndpoint, Ping, Pong, PutMetrics, @@ -69,9 +69,6 @@ pub struct ClientBuilder { registry: Registry, } -const DEFAULT_CAP_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24 * 30); // 1 month -pub const API_SECRET_ENV_VAR_NAME: &str = "IROH_SERVICES_API_SECRET"; - impl ClientBuilder { pub fn new(endpoint: &Endpoint) -> Self { let mut registry = Registry::default(); diff --git a/src/lib.rs b/src/lib.rs index cf25ad13..924e1fee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,7 @@ mod openssh; pub mod api_secret; pub mod caps; pub mod net_diagnostics; +pub mod preset; pub mod protocol; mod built_info { @@ -59,8 +60,9 @@ pub use client_host::{CLIENT_HOST_ALPN, ClientHost, ClientHostClient}; pub use iroh_metrics::Registry; pub use self::{ - api_secret::ApiSecret, - client::{API_SECRET_ENV_VAR_NAME, Client, ClientBuilder}, + api_secret::{API_SECRET_ENV_VAR_NAME, ApiSecret}, + client::{Client, ClientBuilder}, net_diagnostics::{DiagnosticsReport, checks::run_diagnostics}, + preset::{IrohServicesPreset, PresetBuilder, preset}, protocol::ALPN, }; diff --git a/src/preset.rs b/src/preset.rs new file mode 100644 index 00000000..d4b1dfe9 --- /dev/null +++ b/src/preset.rs @@ -0,0 +1,186 @@ +//! An [`iroh::endpoint`] preset tailored for use with iroh-services. +//! +//! [`IrohServicesPreset`] starts from the n0 stock preset (production crypto +//! provider + n0 DNS-based address lookup) and overlays the bits that +//! iroh-services callers usually want to configure together: the relay map +//! the endpoint should use, an optional explicit [`SecretKey`], and an +//! optional [`ApiSecret`] that downstream code can retrieve to wire up a +//! [`crate::Client`]. +//! +//! # Example +//! ```no_run +//! use iroh::Endpoint; +//! +//! async fn run() -> anyhow::Result<()> { +//! let preset = iroh_services::preset() +//! .relays(["https://us-east1.project_username.iroh.link"])? +//! .api_secret_from_env()? +//! .build()?; +//! let endpoint = Endpoint::builder(preset).bind().await?; +//! Ok(()) +//! } +//! ``` +use std::{str::FromStr, time::Duration}; + +use anyhow::{Context, Result, anyhow}; +use iroh::{Endpoint, RelayMap, RelayMode, RelayUrl, SecretKey, endpoint::presets::Preset}; + +use crate::{ + ClientBuilder, + api_secret::{API_SECRET_ENV_VAR_NAME, ApiSecret}, + caps::{Cap, Caps, DEFAULT_CAP_EXPIRY}, +}; + +/// An iroh endpoint preset configured for iroh-services. Build one with +/// [`preset`] or [`IrohServicesPreset::builder`], then pass it to +/// [`iroh::Endpoint::builder`]. +#[derive(Debug, Clone)] +pub struct IrohServicesPreset { + secret_key: SecretKey, + relays: RelayMap, + // not used by the preset, only for creating a client builder + api_secret: ApiSecret, +} + +impl IrohServicesPreset { + /// Start a new builder seeded with iroh-services defaults. Equivalent to + /// the free-standing [`preset`] function. + pub fn builder() -> PresetBuilder { + preset() + } + + /// Returns the [`ApiSecret`] stashed on this preset, if one was set. + /// Useful for handing the same secret to a [`crate::Client`] without + /// plumbing it through twice. + pub fn api_secret(&self) -> &ApiSecret { + &self.api_secret + } + + pub fn client_builder(&self, endpoint: &Endpoint) -> ClientBuilder { + ClientBuilder::new(endpoint) + .api_secret(self.api_secret.clone()) + .unwrap() + } +} + +impl Preset for IrohServicesPreset { + fn apply(self, builder: iroh::endpoint::Builder) -> iroh::endpoint::Builder { + // Inherit n0 defaults (crypto provider + DNS address lookup), then + // overlay our relay map and (optionally) an explicit secret key + let mut builder = iroh::endpoint::presets::N0.apply(builder); + builder = builder.relay_mode(RelayMode::Custom(self.relays)); + builder = builder.secret_key(self.secret_key); + builder + } +} + +/// Fluent builder for [`IrohServicesPreset`]. Construct one through +/// [`preset`] or [`IrohServicesPreset::builder`]. +#[derive(Debug, Clone)] +pub struct PresetBuilder { + cap_expiry: Duration, + secret_key: Option, + relays: RelayMap, + api_secret: Option, +} + +/// Start a new [`IrohServicesPreset`] builder seeded with iroh-services +/// defaults: the n0 production relay map and no explicit secret key (the +/// endpoint will generate one at bind time). +pub fn preset() -> PresetBuilder { + PresetBuilder { + cap_expiry: DEFAULT_CAP_EXPIRY, + secret_key: None, + relays: iroh::endpoint::default_relay_mode().relay_map(), + api_secret: None, + } +} + +impl PresetBuilder { + /// Set the endpoint's long-lived [`SecretKey`]. If left unset the + /// endpoint will generate a fresh random key at bind time. + pub fn secret_key(mut self, secret_key: SecretKey) -> Self { + self.secret_key = Some(secret_key); + self + } + + pub fn relays(mut self, relays: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let parsed = relays + .into_iter() + .map(|s| { + let s = s.as_ref(); + s.parse::() + .with_context(|| format!("invalid relay url {s:?}")) + }) + .collect::>>()?; + + self.relays = RelayMap::from_iter(parsed); + Ok(self) + } + + /// Pick relays via a [`RelayMode`] (e.g. `RelayMode::Staging` or a + /// pre-built `RelayMode::Custom(RelayMap)`). + pub fn relay_mode(mut self, mode: RelayMode) -> Self { + self.relays = mode.relay_map(); + self + } + + /// Pass in a [`RelayMap`] directly, bypassing URL parsing. + pub fn relay_map(mut self, map: RelayMap) -> Self { + self.relays = map; + 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)?; + Ok(self.api_secret(ticket)) + } + + /// set client API secret from an encoded string + pub fn api_secret_from_str(self, secret_key: &str) -> Result { + let key = ApiSecret::from_str(secret_key).context("invalid iroh services api secret")?; + Ok(self.api_secret(key)) + } + + /// Stash an [`ApiSecret`] on the preset so callers can retrieve it later + /// via [`IrohServicesPreset::api_secret`] when constructing a client. + pub fn api_secret(mut self, api_secret: ApiSecret) -> Self { + self.api_secret = Some(api_secret); + self + } + + /// Finalize the configuration into an [`IrohServicesPreset`]. + pub fn build(self) -> Result { + let secret_key = self.secret_key.unwrap_or_else(SecretKey::generate); + + let Some(api_secret) = self.api_secret else { + return Err(anyhow!( + "api secret is required to use iroh_services relay preset" + )); + }; + + // build our token to set on relays + let rcan = crate::caps::create_api_token_from_secret_key( + api_secret.secret.clone(), + secret_key.public(), + self.cap_expiry, + Caps::new([Cap::Relay(crate::caps::RelayCap::Use)]), + )?; + + let mut token = data_encoding::BASE32_NOPAD.encode(&rcan.encode()); + token.make_ascii_lowercase(); + + let relays = self.relays.with_auth_token(token); + + Ok(IrohServicesPreset { + secret_key, + relays, + api_secret, + }) + } +} From c124d3119bbcaaa6706a690976424126eea4c5f4 Mon Sep 17 00:00:00 2001 From: b5 Date: Tue, 19 May 2026 11:51:27 -0400 Subject: [PATCH 2/4] docs(relays): moar docs on relay example --- examples/relays.rs | 34 +++++++++++++++++++++++++--------- src/api_secret.rs | 1 + src/caps.rs | 1 + src/preset.rs | 3 ++- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/examples/relays.rs b/examples/relays.rs index 5eb84f6b..f298bf36 100644 --- a/examples/relays.rs +++ b/examples/relays.rs @@ -1,4 +1,14 @@ -//! This example shows using custom relays provided by iroh services. +//! This example shows common methods for configuring custom relays provided by iroh services. +//! All of these leverage the [`iroh_services::preset`] builder to configure the endpoint, +//! which itself builds an [`iroh::presets::Preset`] to pass to an [`iroh::Endpoint`] on +//! construction. +//! +//! To run these, set IROH_SERVICES_API_SECRET in your environment, using an API secret +//! from your iroh services project, and then run with `cargo run --example relays`. +//! +//! Your app will use only one of these methods, depending on your use case. To test this +//! example with custom relay URLS, you will need to comment out the secret key preset +//! example and use the `relays` method instead, pasting in your own relay URLs. use iroh::{Endpoint, SecretKey}; #[tokio::main] @@ -9,21 +19,21 @@ async fn main() -> anyhow::Result<()> { let _preset = iroh_services::preset().api_secret_from_env()?.build()?; // pro & enterprise projects have access to custom relays, which are set - // with the `relays` method on the builder: + // with the `relays` method on the builder. + // You'll need to replace these strings with the relay urls for your project. let _preset = iroh_services::preset() .relays([ - "https://us-east1.project_username.iroh.link", - "https://eu-west1.project_username.iroh.link", - "https://eu-central1.project_username.iroh.link", + "https://use1-1.relay.username.project.iroh.link", + "https://usw1-1.relay.username.project.iroh.link", + "https://euc1-1.relay.username.project.iroh.link", ])? .api_secret_from_env()? .build()?; // if you are using a secret key from disk, or generally want control over // the ID your endpoint uses, the secret key must be given to the preset - // before passing it to the endpoint (because the endpoint ID is the public - // half of the secret keypair). The access token that's created by the - // preset to access relays is scoped only to the given key. + // before passing it to the endpoint. The access token that the preset creates + // to access relays is scoped only to the given key. // // If no key is provided, a random one is generated and passed to the // endpoint. @@ -37,7 +47,13 @@ async fn main() -> anyhow::Result<()> { // we clone the preset so we can reuse to get a client builder below let endpoint = Endpoint::bind(preset.clone()).await?; - // a client is not required to use + // wait for the endpoint to be online, to prove we have an authorized + // connection to a relay + endpoint.online().await; + + // a client is not required to use, but the preset has a convenience method + // for creating a client builder that uses the same access token as the + // endpoint, so you don't need to pass the secret key separately: let client = preset.client_builder(&endpoint).build().await?; // we can also ping the service just to confirm everything is working diff --git a/src/api_secret.rs b/src/api_secret.rs index 3f6e7ae5..28b69f8a 100644 --- a/src/api_secret.rs +++ b/src/api_secret.rs @@ -10,6 +10,7 @@ use iroh::{EndpointAddr, EndpointId, SecretKey, TransportAddr}; use iroh_tickets::{ParseError, Ticket}; use serde::{Deserialize, Serialize}; +/// The environment variable name this crate checks in builders for an API secret. pub const API_SECRET_ENV_VAR_NAME: &str = "IROH_SERVICES_API_SECRET"; /// The secret material used to connect your services.iroh.computer project. The diff --git a/src/caps.rs b/src/caps.rs index 0b12e4ae..7e2ff466 100644 --- a/src/caps.rs +++ b/src/caps.rs @@ -6,6 +6,7 @@ use n0_future::time::Duration; use rcan::{Capability, Expires, Rcan}; use serde::{Deserialize, Serialize}; +/// How long tokens are valid for by default pub(crate) const DEFAULT_CAP_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24 * 30); // 1 month macro_rules! cap_enum( diff --git a/src/preset.rs b/src/preset.rs index d4b1dfe9..f2432de3 100644 --- a/src/preset.rs +++ b/src/preset.rs @@ -56,6 +56,7 @@ impl IrohServicesPreset { &self.api_secret } + /// Returns a [`ClientBuilder`] pre-configured with this preset's API secret. pub fn client_builder(&self, endpoint: &Endpoint) -> ClientBuilder { ClientBuilder::new(endpoint) .api_secret(self.api_secret.clone()) @@ -164,7 +165,7 @@ impl PresetBuilder { )); }; - // build our token to set on relays + // build our token to interact with relays. This is only scoped to relay use. let rcan = crate::caps::create_api_token_from_secret_key( api_secret.secret.clone(), secret_key.public(), From 275a54eca9c49ade2501a4d3454b7ef894cd30b9 Mon Sep 17 00:00:00 2001 From: b5 Date: Tue, 19 May 2026 22:37:47 -0400 Subject: [PATCH 3/4] cr --- src/caps.rs | 3 +-- src/lib.rs | 2 +- src/preset.rs | 17 ++++++++++++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/caps.rs b/src/caps.rs index 7e2ff466..74a45395 100644 --- a/src/caps.rs +++ b/src/caps.rs @@ -6,8 +6,7 @@ use n0_future::time::Duration; use rcan::{Capability, Expires, Rcan}; use serde::{Deserialize, Serialize}; -/// How long tokens are valid for by default -pub(crate) const DEFAULT_CAP_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24 * 30); // 1 month +pub(crate) const DEFAULT_CAP_EXPIRY: Duration = Duration::from_hours(24 * 30); // 1 month macro_rules! cap_enum( ($enum:item) => { diff --git a/src/lib.rs b/src/lib.rs index 924e1fee..e100d0ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,7 +36,7 @@ mod openssh; pub mod api_secret; pub mod caps; pub mod net_diagnostics; -pub mod preset; +mod preset; pub mod protocol; mod built_info { diff --git a/src/preset.rs b/src/preset.rs index f2432de3..3b8d5d19 100644 --- a/src/preset.rs +++ b/src/preset.rs @@ -49,7 +49,7 @@ impl IrohServicesPreset { preset() } - /// Returns the [`ApiSecret`] stashed on this preset, if one was set. + /// Returns the [`ApiSecret`] used to create this preset. /// Useful for handing the same secret to a [`crate::Client`] without /// plumbing it through twice. pub fn api_secret(&self) -> &ApiSecret { @@ -58,6 +58,8 @@ impl IrohServicesPreset { /// Returns a [`ClientBuilder`] pre-configured with this preset's API secret. pub fn client_builder(&self, endpoint: &Endpoint) -> ClientBuilder { + // unwrap is ok here because the api_secret has been factored + // to the point that it can no longer fail. ClientBuilder::new(endpoint) .api_secret(self.api_secret.clone()) .unwrap() @@ -105,6 +107,19 @@ impl PresetBuilder { self } + /// Set relay URLs. This method accepts any iterator of &str, allowing the + /// common pattern: + /// ```no_run + /// let _preset = iroh_services::preset() + /// .relays([ + /// "https://us-east1.project_username.iroh.link", + /// "https://eu-west1.project_username.iroh.link", + /// "https://eu-central1.project_username.iroh.link", + /// ])? + /// .api_secret_from_env()? + /// .build()?; + /// Ok(()) + /// ``` pub fn relays(mut self, relays: I) -> Result where I: IntoIterator, From f20772abdcf3d4d59bea97cf7bc070712f39ec14 Mon Sep 17 00:00:00 2001 From: b5 Date: Wed, 20 May 2026 06:13:04 -0400 Subject: [PATCH 4/4] better doc example --- src/preset.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/preset.rs b/src/preset.rs index 3b8d5d19..6aa14db3 100644 --- a/src/preset.rs +++ b/src/preset.rs @@ -110,15 +110,17 @@ impl PresetBuilder { /// Set relay URLs. This method accepts any iterator of &str, allowing the /// common pattern: /// ```no_run - /// let _preset = iroh_services::preset() - /// .relays([ - /// "https://us-east1.project_username.iroh.link", - /// "https://eu-west1.project_username.iroh.link", - /// "https://eu-central1.project_username.iroh.link", - /// ])? - /// .api_secret_from_env()? - /// .build()?; - /// Ok(()) + /// fn build() -> anyhow::Result<()> { + /// let _preset = iroh_services::preset() + /// .relays([ + /// "https://us-east1.project_username.iroh.link", + /// "https://eu-west1.project_username.iroh.link", + /// "https://eu-central1.project_username.iroh.link", + /// ])? + /// .api_secret_from_env()? + /// .build()?; + /// Ok(()) + /// } /// ``` pub fn relays(mut self, relays: I) -> Result where