Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 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 @@ -42,6 +42,7 @@ postcard = { version = "1.1.3", features = ["use-std"] }
futures-buffered = "0.2.12"
tokio-util = "0.7.16"
n0-error = "0.1.0"
iroh-tickets = "0.2.0"

[dev-dependencies]
tokio = { version = "1.45", features = ["macros", "rt", "rt-multi-thread"] }
Expand Down
49 changes: 48 additions & 1 deletion src/caps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::{collections::BTreeSet, fmt, str::FromStr, time::Duration};

use anyhow::{Context, Result, bail};
use ed25519_dalek::SigningKey;
use iroh::EndpointId;
use iroh::{EndpointId, SecretKey};
use rcan::{Capability, Expires, Rcan};
use serde::{Deserialize, Serialize};
use ssh_key::PrivateKey as SshPrivateKey;
Expand Down Expand Up @@ -66,6 +66,8 @@ impl std::ops::Deref for Caps {
pub enum Cap {
#[strum(to_string = "all")]
All,
#[strum(to_string = "client")]
Client,
#[strum(to_string = "relay:{0}")]
Relay(RelayCap),
#[strum(to_string = "metrics:{0}")]
Expand Down Expand Up @@ -107,6 +109,15 @@ impl Caps {
Self::V0(CapSet::new(caps))
}

/// the class of capabilities that n0des will accept when deriving from a
/// shared secret like a [`N0desTicket`]. These should be "client" capabilities:
/// typically for users of an app
pub fn for_shared_secret() -> Self {
Self::new([Cap::Client])
}

/// The maximum set of capabilities. n0des will only accept these capabilities
/// when deriving from a secret that is registered with n0des, like an SSH key
pub fn all() -> Self {
Self::new([Cap::All])
}
Expand Down Expand Up @@ -145,13 +156,23 @@ impl Capability for Cap {
fn permits(&self, other: &Self) -> bool {
match (self, other) {
(Cap::All, _) => true,
(Cap::Client, other) => client_capabilities(other),
(Cap::Relay(slf), Cap::Relay(other)) => slf.permits(other),
(Cap::Metrics(slf), Cap::Metrics(other)) => slf.permits(other),
(_, _) => false,
}
}
}

fn client_capabilities(other: &Cap) -> bool {
match other {
Cap::All => false,
Cap::Client => true,
Cap::Relay(RelayCap::Use) => true,
Cap::Metrics(MetricsCap::PutAny) => true,
}
}

impl Capability for MetricsCap {
fn permits(&self, other: &Self) -> bool {
match (self, other) {
Expand Down Expand Up @@ -257,6 +278,20 @@ pub fn create_api_token(
Ok(can)
}

/// Create an rcan token for the api access from an iroh secret key
pub fn create_api_token_from_secret_key(
private_key: SecretKey,
local_id: EndpointId,
max_age: Duration,
capability: Caps,
) -> Result<Rcan<Caps>> {
let issuer = SigningKey::from_bytes(&private_key.to_bytes());
let audience = local_id.as_verifying_key();
let can =
Rcan::issuing_builder(&issuer, audience, capability).sign(Expires::valid_for(max_age));
Ok(can)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -301,4 +336,16 @@ mod tests {
assert!(!metrics.permits(&relay));
assert!(!relay.permits(&metrics));
}

#[test]
fn client_caps() {
let client = Caps::new([Cap::Client]);

let all = Caps::new([Cap::All]);
let metrics = Caps::new([MetricsCap::PutAny]);
let relay = Caps::new([RelayCap::Use]);
assert!(client.permits(&metrics));
assert!(client.permits(&relay));
assert!(!client.permits(&all));
}
}
127 changes: 115 additions & 12 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
use std::{
env::VarError,
path::Path,
str::FromStr,
sync::{Arc, RwLock},
time::Duration,
};

use anyhow::{Result, anyhow, ensure};
use iroh::{Endpoint, EndpointAddr, EndpointId, endpoint::ConnectError};
use iroh_metrics::{Registry, encoding::Encoder};
use irpc_iroh::IrohRemoteConnection;
use irpc_iroh::IrohLazyRemoteConnection;
use n0_error::StackResultExt;
use n0_future::task::AbortOnDropHandle;
use rcan::Rcan;
use tracing::warn;
use tracing::{debug, trace, warn};
use uuid::Uuid;

use crate::{
caps::Caps,
protocol::{ALPN, Auth, N0desClient, Ping, PutMetrics, RemoteError},
ticket::N0desTicket,
};

#[derive(Debug)]
Expand All @@ -24,12 +28,19 @@ pub struct Client {
_metrics_task: Option<AbortOnDropHandle<()>>,
}

/// Constructs an IPS client
impl Drop for Client {
fn drop(&mut self) {
debug!("n0des client is being dropped");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we like, not?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know, but hear me out: on two separate occasions while working with n0des I was like "I don't need the client, I just want metrics", so I typed:

iroh_n0des::Client::builder(&endpoint).ticket("$TICKET").bind().await;

// set up router here...

and I spent an hour trying to figure out why on earth metrics weren't sending. It was a super not fun footgun that I'm sure others will run into, so at least leaving a debug message when drop is otherwise silently aborting the entire actor task is a nice-to-have for mere mortals like me. If you know of a better way, I'm all ears, but logging this at the debug level feels like it'll cut down on confusion

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is a much nicer way to deal with this, just put #[must_use] on the bind function

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but also, be prepared in both cases for other rust fans to yell 😅 you don't just drop an iroh endpoint either and then wonder why it is gone

}
}

/// Constructs a n0des client
pub struct ClientBuilder {
cap_expiry: Duration,
cap: Option<Rcan<Caps>>,
endpoint: Endpoint,
enable_metrics: Option<Duration>,
remote: Option<EndpointAddr>,
}

const DEFAULT_CAP_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24 * 30); // 1 month
Expand All @@ -40,7 +51,8 @@ impl ClientBuilder {
cap: None,
cap_expiry: DEFAULT_CAP_EXPIRY,
endpoint: endpoint.clone(),
enable_metrics: Some(Duration::from_secs(60)),
enable_metrics: Some(Duration::from_secs(10)),
remote: None,
}
}

Expand All @@ -58,6 +70,40 @@ impl ClientBuilder {
self
}

/// Check N0DES_SECRET environment variable to supply a N0desTicket
pub fn secret_from_env(self) -> Result<Self> {
match std::env::var("N0DES_SECRET") {
Ok(ticket_string) => {
let ticket =
N0desTicket::from_str(&ticket_string).context("invalid N0DES_SECRET")?;
self.ticket(ticket)
}
Err(VarError::NotPresent) => {
Err(anyhow!("N0DES_SECRET environment variable is not set"))
}
Err(VarError::NotUnicode(e)) => Err(anyhow!(
"N0DES_SECRET environment variable is not valid unicode: {:?}",
e
)),
}
}

/// Use a shared secret & remote n0des endpoint ID contained within a ticket
/// to construct a n0des client. The resulting client will have "Client"
/// capabilities.
pub fn ticket(mut self, ticket: N0desTicket) -> Result<Self> {
let local_id = self.endpoint.id();
let rcan = crate::caps::create_api_token_from_secret_key(
ticket.secret,
local_id,
self.cap_expiry,
Caps::for_shared_secret(),
)?;

self.remote = Some(ticket.remote);
self.rcan(rcan)
}

/// Loads the private ssh key from the given path, and creates the needed capability.
pub async fn ssh_key_from_file<P: AsRef<Path>>(self, path: P) -> Result<Self> {
let file_content = tokio::fs::read_to_string(path).await?;
Expand Down Expand Up @@ -85,21 +131,27 @@ impl ClientBuilder {
Ok(self)
}

/// Sets the remote to dial, must be provided either directly by calling
/// this method, or via the
pub fn remote(mut self, remote: impl Into<EndpointAddr>) -> Self {
self.remote = Some(remote.into());
self
}

/// Create a new client, connected to the provide service node
pub async fn build(self, remote: impl Into<EndpointAddr>) -> Result<Client, BuildError> {
pub async fn build(self) -> Result<Client, BuildError> {
debug!("starting iroh-n0des client");
let remote = self.remote.ok_or(BuildError::MissingRemote)?;
let cap = self.cap.ok_or(BuildError::MissingCapability)?;
let conn = self
.endpoint
.connect(remote.into(), ALPN)
.await
.map_err(BuildError::Connect)?;
let conn = IrohRemoteConnection::new(conn);

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

// If auth fails, the connection is aborted.
let () = client.rpc(Auth { caps: cap }).await?;

let metrics_task = self.enable_metrics.map(|interval| {
debug!(interval = ?interval, "starting metrics task");
AbortOnDropHandle::new(n0_future::task::spawn(
MetricsTask {
client: client.clone(),
Expand All @@ -119,6 +171,8 @@ impl ClientBuilder {

#[derive(thiserror::Error, Debug)]
pub enum BuildError {
#[error("Missing remote endpoint to dial")]
MissingRemote,
#[error("Missing capability")]
MissingCapability,
#[error("Unauthorized")]
Expand Down Expand Up @@ -191,6 +245,7 @@ impl MetricsTask {

loop {
metrics_timer.tick().await;
trace!("metrics send tick");
if let Err(err) = self.send_metrics(&mut encoder).await {
warn!("failed to push metrics: {:#?}", err);
}
Expand All @@ -203,7 +258,55 @@ impl MetricsTask {
session_id: self.session_id,
update,
};
self.client.rpc(req).await??;

self.client
.rpc(req)
.await
.inspect_err(|e| warn!("rpc send metrics error: {e}"))?
.inspect_err(|e| warn!("metrics server response error: {e}"))?;
trace!("sent metrics");
Ok(())
}
}

#[cfg(test)]
mod tests {
use iroh::{Endpoint, EndpointAddr, SecretKey};

use crate::{Client, caps::Caps, ticket::N0desTicket};

#[tokio::test]
async fn test_builder_from_env() {
// construct
let mut rng = rand::rng();
let shared_secret = SecretKey::generate(&mut rng);
let fake_endpoint_id = SecretKey::generate(&mut rng).public();
let n0des_ticket = N0desTicket::new(shared_secret.clone(), fake_endpoint_id);
unsafe {
std::env::set_var("N0DES_SECRET", n0des_ticket.to_string());
};

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

let builder = Client::builder(&endpoint).secret_from_env().unwrap();

let fake_endpoint_addr: EndpointAddr = fake_endpoint_id.into();
assert_eq!(builder.remote, Some(fake_endpoint_addr));

let rcan = crate::caps::create_api_token_from_secret_key(
shared_secret,
endpoint.id(),
builder.cap_expiry,
Caps::for_shared_secret(),
)
.unwrap();
assert_eq!(builder.cap, Some(rcan));

unsafe {
std::env::remove_var("N0DES_SECRET");
};
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod n0des;
pub mod caps;
pub mod protocol;
pub mod simulation;
pub mod ticket;

pub use iroh_n0des_macro::sim;

Expand Down
3 changes: 2 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ pub async fn main() -> Result<()> {
let mut rpc_client = Client::builder(&client_endpoint)
.metrics_interval(Duration::from_secs(2))
.ssh_key(&alice_ssh_key)?
.build(remote_addr.clone())
.remote(remote_addr.clone())
.build()
.await?;

rpc_client.ping().await?;
Expand Down
Loading
Loading