Skip to main content

iroh_services/
client.rs

1use std::{
2    collections::BTreeMap,
3    fmt::Display,
4    str::FromStr,
5    sync::{Arc, RwLock},
6};
7
8use anyhow::{Result, ensure};
9use iroh::{
10    Endpoint, EndpointAddr, EndpointId,
11    endpoint::{ConnectError, Connection},
12};
13use iroh_metrics::{MetricsGroup, Registry, encoding::Encoder};
14use irpc::{Channels, RpcMessage, WithChannels, channel::none::NoReceiver};
15use irpc_iroh::IrohRemoteConnection;
16use n0_error::StackResultExt;
17use n0_future::{
18    task::{self, AbortOnDropHandle},
19    time::{self, Duration},
20};
21use rcan::Rcan;
22use tokio::sync::oneshot;
23use tokio_util::sync::CancellationToken;
24use tracing::{debug, trace, warn};
25use uuid::Uuid;
26
27use crate::{
28    api_secret::{API_SECRET_ENV_VAR_NAME, ApiSecret},
29    caps::{Caps, DEFAULT_CAP_EXPIRY},
30    net_diagnostics::{DiagnosticsReport, checks::run_diagnostics},
31    protocol::{
32        ALPN, Auth, GrantCap, IrohServicesClient, IrohServicesProtocol, NameEndpoint, Ping, Pong,
33        PutMetrics, PutNetworkDiagnostics, RemoteError, ServicesMessage, SetAttributes, SetGroup,
34    },
35};
36
37/// Client is the main handle for interacting with iroh-services. It communicates with
38/// iroh-services entirely through an iroh endpoint, and is configured through a builder.
39/// Client requires either an Ssh Key or [`ApiSecret`]
40///
41/// ```no_run
42/// use iroh::{Endpoint, endpoint::presets};
43/// use iroh_services::Client;
44///
45/// async fn build_client() -> anyhow::Result<()> {
46///     let endpoint = Endpoint::bind(presets::N0).await?;
47///
48///     // needs IROH_SERVICES_API_SECRET set to an environment variable
49///     // client will now push endpoint metrics to iroh-services.
50///     let client = Client::builder(&endpoint)
51///         .api_secret_from_str("MY_API_SECRET")?
52///         .build()
53///         .await;
54///
55///     Ok(())
56/// }
57/// ```
58///
59/// [`ApiSecret`]: crate::api_secret::ApiSecret
60#[derive(Debug, Clone)]
61pub struct Client {
62    // owned clone of the endpoint for diagnostics, and for connection restarts on actor close
63    endpoint: Endpoint,
64    message_channel: tokio::sync::mpsc::Sender<ClientActorMessage>,
65    /// Cancelled by [`Client::shutdown`] to stop whatever the actor is doing.
66    shutdown: CancellationToken,
67    _actor_task: Arc<AbortOnDropHandle<()>>,
68}
69
70/// ClientBuilder provides configures and builds a iroh-services client, typically
71/// created with [`Client::builder`]
72pub struct ClientBuilder {
73    cap_expiry: Duration,
74    cap: Option<Rcan<Caps>>,
75    endpoint: Endpoint,
76    name: Option<String>,
77    group: Option<String>,
78    attributes: Option<BTreeMap<String, String>>,
79    metrics_interval: Option<Duration>,
80    remote: Option<EndpointAddr>,
81    registry: Registry,
82}
83
84impl ClientBuilder {
85    pub fn new(endpoint: &Endpoint) -> Self {
86        let mut registry = Registry::default();
87        registry.register_all(endpoint.metrics());
88
89        Self {
90            cap: None,
91            cap_expiry: DEFAULT_CAP_EXPIRY,
92            endpoint: endpoint.clone(),
93            name: None,
94            group: None,
95            attributes: None,
96            metrics_interval: Some(Duration::from_secs(60)),
97            remote: None,
98            registry,
99        }
100    }
101
102    /// Register a metrics group to forward to iroh-services
103    ///
104    /// The default registered metrics uses only the endpoint
105    pub fn register_metrics_group(mut self, metrics_group: Arc<dyn MetricsGroup>) -> Self {
106        self.registry.register(metrics_group);
107        self
108    }
109
110    /// Set the metrics collection interval
111    ///
112    /// Defaults to enabled, every 60 seconds.
113    pub fn metrics_interval(mut self, interval: Duration) -> Self {
114        self.metrics_interval = Some(interval);
115        self
116    }
117
118    /// Disable metrics collection.
119    pub fn disable_metrics_interval(mut self) -> Self {
120        self.metrics_interval = None;
121        self
122    }
123
124    /// Set an optional human-readable name for the endpoint, making its metrics
125    /// easier to identify.
126    ///
127    /// Often a database user id, machine name, or other stable identifier from
128    /// your application. A name must be 2 to 128 bytes of UTF-8; uniqueness is not
129    /// enforced, so different endpoints may share a name.
130    ///
131    /// Validation errors are returned here. The name is sent to the server after
132    /// the client authenticates; a failure to send it at that point is logged at
133    /// warn level rather than returned; use [`Client::set_name`] to set it later
134    /// with explicit error handling.
135    pub fn name(mut self, name: impl Into<String>) -> Result<Self> {
136        let name = name.into();
137        validate_name(&name).map_err(BuildError::InvalidName)?;
138        self.name = Some(name);
139        Ok(self)
140    }
141
142    /// Attach the endpoint to a single named group when the client first
143    /// authenticates.
144    ///
145    /// A group name must be 2 to 128 bytes of UTF-8. Validation errors are returned
146    /// here. The group is sent to the server after the client authenticates; a
147    /// failure to send it at that point is logged at warn level rather than
148    /// returned; use [`Client::set_group`] to set it later with explicit error
149    /// handling.
150    pub fn group(mut self, group: impl Into<String>) -> Result<Self> {
151        let group = group.into();
152        validate_name(&group).map_err(BuildError::InvalidGroup)?;
153        self.group = Some(group);
154        Ok(self)
155    }
156
157    /// Attach arbitrary key-value attributes to the endpoint when the client
158    /// first authenticates. Accepts any iterable of `(key, value)` pairs:
159    ///
160    /// ```no_run
161    /// # use iroh::{Endpoint, endpoint::presets};
162    /// # use iroh_services::Client;
163    /// # async fn example(endpoint: &Endpoint) -> anyhow::Result<()> {
164    /// let _ = Client::builder(endpoint).attributes([("env", "prod"), ("region", "us-west")])?;
165    /// # Ok(()) }
166    /// ```
167    ///
168    /// Each key must be 2 to 128 bytes of UTF-8; values may be empty and are capped
169    /// at 128 bytes; at most 128 entries are allowed. Validation errors are
170    /// returned here. The attributes are sent to the server after the client
171    /// authenticates; a failure to send them at that point is logged at warn
172    /// level rather than returned; use [`Client::set_attributes`] to set them
173    /// later with explicit error handling.
174    pub fn attributes<I, K, V>(mut self, attrs: I) -> Result<Self>
175    where
176        I: IntoIterator<Item = (K, V)>,
177        K: Into<String>,
178        V: Into<String>,
179    {
180        let collected: BTreeMap<String, String> = attrs
181            .into_iter()
182            .map(|(k, v)| (k.into(), v.into()))
183            .collect();
184        validate_attributes(&collected).map_err(BuildError::InvalidAttributes)?;
185        self.attributes = Some(collected);
186        Ok(self)
187    }
188
189    /// Check IROH_SERVICES_API_SECRET environment variable for a valid API secret
190    pub fn api_secret_from_env(self) -> Result<Self> {
191        let ticket = ApiSecret::from_env_var(API_SECRET_ENV_VAR_NAME)?;
192        self.api_secret(ticket)
193    }
194
195    /// set client API secret from an encoded string
196    pub fn api_secret_from_str(self, secret_key: &str) -> Result<Self> {
197        let key = ApiSecret::from_str(secret_key).context("invalid iroh services api secret")?;
198        self.api_secret(key)
199    }
200
201    /// Use a shared secret & remote iroh-services endpoint ID contained within a ticket
202    /// to construct a iroh-services client. The resulting client will have "Client"
203    /// capabilities.
204    ///
205    /// API secrets include remote details within them, and will set both the
206    /// remote and rcan values on the builder
207    pub fn api_secret(mut self, ticket: ApiSecret) -> Result<Self> {
208        let local_id = self.endpoint.id();
209        let rcan = crate::caps::create_api_token_from_secret_key(
210            ticket.secret,
211            local_id,
212            self.cap_expiry,
213            Caps::for_shared_secret(),
214        )?;
215
216        self.remote = Some(ticket.remote);
217        self.rcan(rcan)
218    }
219
220    /// Loads the private ssh key from the given path, and creates the needed capability.
221    ///
222    /// The file must contain an unencrypted PEM-encoded OpenSSH ed25519 private key.
223    #[cfg(not(wasm_browser))]
224    pub async fn ssh_key_from_file<P: AsRef<std::path::Path>>(self, path: P) -> Result<Self> {
225        let file_content = tokio::fs::read_to_string(path).await?;
226        self.ssh_key(&file_content)
227    }
228
229    /// Creates the capability from the provided PEM-encoded OpenSSH ed25519 private key.
230    #[cfg(not(wasm_browser))]
231    pub fn ssh_key(mut self, pem: &str) -> Result<Self> {
232        let local_id = self.endpoint.id();
233        let rcan = crate::caps::create_api_token_from_openssh_pem(
234            pem,
235            local_id,
236            self.cap_expiry,
237            Caps::all(),
238        )?;
239        self.cap.replace(rcan);
240
241        Ok(self)
242    }
243
244    /// Sets the rcan directly.
245    pub fn rcan(mut self, cap: Rcan<Caps>) -> Result<Self> {
246        ensure!(
247            EndpointId::from_verifying_key(*cap.audience()) == self.endpoint.id(),
248            "invalid audience"
249        );
250        self.cap.replace(cap);
251        Ok(self)
252    }
253
254    /// Sets the remote to dial, must be provided either directly by calling
255    /// this method, or through calling the api_secret builder methods.
256    pub fn remote(mut self, remote: impl Into<EndpointAddr>) -> Self {
257        self.remote = Some(remote.into());
258        self
259    }
260
261    /// Create a new client, connected to the provide service node
262    #[must_use = "dropping the client will silently cancel all client tasks"]
263    pub async fn build(self) -> Result<Client, BuildError> {
264        debug!("starting iroh-services client");
265        let remote = self.remote.ok_or(BuildError::MissingRemote)?;
266        let capabilities = self.cap.ok_or(BuildError::MissingCapability)?;
267
268        let registry = Arc::new(RwLock::new(self.registry));
269        let (tx, rx) = tokio::sync::mpsc::channel(1);
270        let shutdown = CancellationToken::new();
271        let actor_task = AbortOnDropHandle::new(task::spawn(
272            ClientActor {
273                capabilities,
274                endpoint: self.endpoint.clone(),
275                remote,
276                client: None,
277                name: self.name.clone(),
278                group: self.group.clone(),
279                attributes: self.attributes.clone().unwrap_or_default(),
280                session_id: Uuid::new_v4(),
281                encoder: Encoder::new(registry.clone()),
282                registry,
283            }
284            .run(self.metrics_interval, rx, shutdown.clone()),
285        ));
286
287        Ok(Client {
288            endpoint: self.endpoint,
289            message_channel: tx,
290            shutdown,
291            _actor_task: Arc::new(actor_task),
292        })
293    }
294}
295
296#[derive(thiserror::Error, Debug)]
297#[non_exhaustive]
298pub enum BuildError {
299    #[error("Missing remote endpoint to dial")]
300    MissingRemote,
301    #[error("Missing capability")]
302    MissingCapability,
303    #[error("Unauthorized")]
304    Unauthorized,
305    #[error("Remote error: {0}")]
306    Remote(#[from] RemoteError),
307    #[error("Rpc connection error: {0}")]
308    Rpc(irpc::Error),
309    #[error("Connection error: {0}")]
310    Connect(ConnectError),
311    #[error("Invalid endpoint name: {0}")]
312    InvalidName(#[from] ValidateNameError),
313    #[error("Invalid endpoint group: {0}")]
314    InvalidGroup(ValidateNameError),
315    #[error("Invalid endpoint attributes: {0}")]
316    InvalidAttributes(#[from] ValidateAttributesError),
317}
318
319impl From<irpc::Error> for BuildError {
320    fn from(value: irpc::Error) -> Self {
321        match value {
322            irpc::Error::Request {
323                source:
324                    irpc::RequestError::Connection {
325                        source: iroh::endpoint::ConnectionError::ApplicationClosed(frame),
326                        ..
327                    },
328                ..
329            } if frame.error_code == 401u32.into() => Self::Unauthorized,
330            value => Self::Rpc(value),
331        }
332    }
333}
334
335/// How long [`Client::shutdown`] lets a request already in flight finish.
336///
337/// Dropping a request mid-flight resets its stream, and the server treats that
338/// as the connection failing: it stops reading requests and tears the
339/// connection down. Letting the request finish keeps the connection usable for
340/// the final metrics push. Requests to a responsive server complete in
341/// fast, so this only runs out when the server has stopped answering,
342/// in which case the final metrics push is then skipped.
343const SHUTDOWN_GRACE: Duration = Duration::from_secs(3);
344
345/// How long [`Client::shutdown`] waits for the final metrics push.
346///
347/// The push goes over an established connection, so it is normally well under
348/// this. The bound covers a peer that has gone silent without closing, where
349/// the request would otherwise hang until the connection times out.
350const SHUTDOWN_FLUSH_TIMEOUT: Duration = Duration::from_secs(2);
351
352/// Minimum length in bytes for an endpoint name.
353pub const CLIENT_NAME_MIN_LENGTH: usize = 2;
354/// Maximum length in bytes for an endpoint name.
355pub const CLIENT_NAME_MAX_LENGTH: usize = 128;
356
357/// Error returned when an endpoint name fails validation.
358#[derive(Debug, thiserror::Error)]
359pub enum ValidateNameError {
360    #[error("Name is too long (must be no more than {CLIENT_NAME_MAX_LENGTH} bytes).")]
361    TooLong,
362    #[error("Name is too short (must be at least {CLIENT_NAME_MIN_LENGTH} bytes).")]
363    TooShort,
364}
365
366fn validate_name(name: &str) -> Result<(), ValidateNameError> {
367    if name.len() < CLIENT_NAME_MIN_LENGTH {
368        Err(ValidateNameError::TooShort)
369    } else if name.len() > CLIENT_NAME_MAX_LENGTH {
370        Err(ValidateNameError::TooLong)
371    } else {
372        Ok(())
373    }
374}
375
376/// Maximum length in bytes for an attribute value. Values may be empty.
377pub const CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH: usize = 128;
378/// Maximum number of entries allowed in the attributes map.
379pub const CLIENT_ATTRIBUTES_MAX_COUNT: usize = 128;
380
381/// Error returned when an attributes map fails validation.
382#[derive(Debug, thiserror::Error)]
383pub enum ValidateAttributesError {
384    #[error("Too many attributes (must be no more than {CLIENT_ATTRIBUTES_MAX_COUNT}).")]
385    TooManyEntries,
386    #[error("Invalid attribute key: {0}")]
387    InvalidKey(#[from] ValidateNameError),
388    #[error(
389        "Attribute value too long (must be no more than {CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH} bytes)."
390    )]
391    ValueTooLong,
392}
393
394fn validate_attributes(attrs: &BTreeMap<String, String>) -> Result<(), ValidateAttributesError> {
395    if attrs.len() > CLIENT_ATTRIBUTES_MAX_COUNT {
396        return Err(ValidateAttributesError::TooManyEntries);
397    }
398    for (k, v) in attrs {
399        validate_name(k)?;
400        if v.len() > CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH {
401            return Err(ValidateAttributesError::ValueTooLong);
402        }
403    }
404    Ok(())
405}
406
407#[derive(thiserror::Error, Debug)]
408#[non_exhaustive]
409pub enum Error {
410    #[error("Invalid endpoint name: {0}")]
411    InvalidName(#[from] ValidateNameError),
412    #[error("Invalid endpoint group: {0}")]
413    InvalidGroup(ValidateNameError),
414    #[error("Invalid endpoint attributes: {0}")]
415    InvalidAttributes(#[from] ValidateAttributesError),
416    #[error("Remote error: {0}")]
417    Remote(#[from] RemoteError),
418    #[error("Connection error: {0}")]
419    Connect(#[from] ConnectError),
420    #[error("Rpc error: {0}")]
421    Rpc(#[from] irpc::Error),
422    #[error(transparent)]
423    Other(#[from] anyhow::Error),
424    #[error("Local client actor is stopped, cannot send requests")]
425    ActorStopped,
426}
427
428impl From<tokio::sync::mpsc::error::SendError<ClientActorMessage>> for Error {
429    fn from(_value: tokio::sync::mpsc::error::SendError<ClientActorMessage>) -> Self {
430        Error::ActorStopped
431    }
432}
433
434impl From<tokio::sync::oneshot::error::RecvError> for Error {
435    fn from(_value: tokio::sync::oneshot::error::RecvError) -> Self {
436        Error::ActorStopped
437    }
438}
439
440impl Client {
441    pub fn builder(endpoint: &Endpoint) -> ClientBuilder {
442        ClientBuilder::new(endpoint)
443    }
444
445    /// Read the current endpoint name from the local client.
446    pub async fn name(&self) -> Result<Option<String>, Error> {
447        let (tx, rx) = oneshot::channel();
448        self.message_channel
449            .send(ClientActorMessage::ReadName { done: tx })
450            .await?;
451        rx.await.map_err(Into::into)
452    }
453
454    /// Read the current endpoint group from the local client.
455    pub async fn group(&self) -> Result<Option<String>, Error> {
456        let (tx, rx) = oneshot::channel();
457        self.message_channel
458            .send(ClientActorMessage::ReadGroup { done: tx })
459            .await?;
460        rx.await.map_err(Into::into)
461    }
462
463    /// Name the active endpoint cloud-side.
464    ///
465    /// names can be any UTF-8 string, with a min length of 2 bytes, and
466    /// maximum length of 128 bytes. **name uniqueness is not enforced.**
467    pub async fn set_name(&self, name: impl Into<String>) -> Result<(), Error> {
468        let name = name.into();
469        validate_name(&name)?;
470        debug!(name_len = name.len(), "calling set name");
471        let (tx, rx) = oneshot::channel();
472        self.message_channel
473            .send(ClientActorMessage::NameEndpoint { name, done: tx })
474            .await?;
475        rx.await?
476    }
477
478    /// Attach the active endpoint to a single named group cloud-side.
479    ///
480    /// A group name must be 2 to 128 bytes of UTF-8.
481    pub async fn set_group(&self, group: impl Into<String>) -> Result<(), Error> {
482        let group: String = group.into();
483        validate_name(&group).map_err(Error::InvalidGroup)?;
484        debug!(%group, "calling set group");
485        let (tx, rx) = oneshot::channel();
486        self.message_channel
487            .send(ClientActorMessage::SetGroup { group, done: tx })
488            .await?;
489        rx.await?
490    }
491
492    /// Replace the arbitrary key-value attributes on the active endpoint cloud-side.
493    ///
494    /// Accepts any iterable of `(key, value)` pairs (arrays of tuples, `Vec`s,
495    /// `HashMap`s, `BTreeMap`s, etc.), so most calls fit on a single line:
496    ///
497    /// ```no_run
498    /// # use iroh_services::Client;
499    /// # async fn example(client: Client) -> anyhow::Result<()> {
500    /// client
501    ///     .set_attributes([("env", "prod"), ("region", "us-west")])
502    ///     .await?;
503    /// # Ok(()) }
504    /// ```
505    ///
506    /// Each key must be 2 to 128 bytes of UTF-8; values may be empty and are limited
507    /// to 128 bytes; at most 128 entries are allowed. Each call fully replaces
508    /// the prior set; passing an empty iterator clears all attributes.
509    pub async fn set_attributes<I, K, V>(&self, attrs: I) -> Result<(), Error>
510    where
511        I: IntoIterator<Item = (K, V)>,
512        K: Into<String>,
513        V: Into<String>,
514    {
515        let collected: BTreeMap<String, String> = attrs
516            .into_iter()
517            .map(|(k, v)| (k.into(), v.into()))
518            .collect();
519        validate_attributes(&collected)?;
520        debug!(attr_count = collected.len(), "calling set attributes");
521        let (tx, rx) = oneshot::channel();
522        self.message_channel
523            .send(ClientActorMessage::SetAttributes {
524                attributes: collected,
525                done: tx,
526            })
527            .await?;
528        rx.await?
529    }
530
531    /// Set or replace a single attribute, merging it into the endpoint's existing
532    /// attributes rather than replacing the whole set.
533    ///
534    /// A convenience over [`set_attributes`](Self::set_attributes) when you only
535    /// need to change one value. The key must be 2 to 128 bytes of UTF-8 and the
536    /// value is limited to 128 bytes; the merged set must stay within 128 entries.
537    pub async fn set_attribute(
538        &self,
539        key: impl Into<String>,
540        value: impl Into<String>,
541    ) -> Result<(), Error> {
542        // Validation happens in the actor against the merged set (current
543        // attributes plus this entry), since only there is the current set
544        // known. Merging can exceed the entry-count limit even when this
545        // single entry is valid.
546        let (tx, rx) = oneshot::channel();
547        self.message_channel
548            .send(ClientActorMessage::SetAttribute {
549                key: key.into(),
550                value: value.into(),
551                done: tx,
552            })
553            .await?;
554        rx.await?
555    }
556
557    /// Pings the remote node.
558    pub async fn ping(&self) -> Result<Pong, Error> {
559        let (tx, rx) = oneshot::channel();
560        self.message_channel
561            .send(ClientActorMessage::Ping { done: tx })
562            .await?;
563        rx.await?
564    }
565
566    /// immediately send a single dump of metrics to iroh-services. It's not necessary
567    /// to call this function if you're using a non-zero metrics interval,
568    /// which will automatically propagate metrics on the set interval for you
569    pub async fn push_metrics(&self) -> Result<(), Error> {
570        let (tx, rx) = oneshot::channel();
571        self.message_channel
572            .send(ClientActorMessage::SendMetrics { done: tx })
573            .await?;
574        rx.await?
575    }
576
577    /// Shuts down the client, after pushing one final round of metrics.
578    ///
579    /// Calling this aborts any inflight requests, and all subsequent requests
580    /// triggered via methods on [`Self`] will fail. If the client is currently
581    /// connected, it will send one final metrics update before shutting down
582    /// the client actor.
583    ///
584    /// Dropping the client without calling this method immediately stops the
585    /// actor without a final metrics push.
586    pub async fn shutdown(&self) {
587        self.shutdown.cancel();
588        // The actor drops the inbox receiver on its way out.
589        self.message_channel.closed().await;
590    }
591
592    /// Grant capabilities to a remote endpoint. Creates a signed RCAN token
593    /// and sends it to iroh-services for storage. The remote can then use this token
594    /// when dialing back to authorize its requests.
595    pub async fn grant_capability(
596        &self,
597        remote_id: EndpointId,
598        caps: impl IntoIterator<Item = impl Into<crate::caps::Cap>>,
599    ) -> Result<(), Error> {
600        let cap = crate::caps::create_grant_token(
601            self.endpoint.secret_key().clone(),
602            remote_id,
603            DEFAULT_CAP_EXPIRY,
604            Caps::new(caps),
605        )
606        .map_err(Error::Other)?;
607
608        let (tx, rx) = oneshot::channel();
609        self.message_channel
610            .send(ClientActorMessage::GrantCap {
611                cap: Box::new(cap),
612                done: tx,
613            })
614            .await?;
615        rx.await?
616    }
617
618    /// run local network status diagnostics, optionally uploading the results
619    pub async fn net_diagnostics(&self, send: bool) -> Result<DiagnosticsReport, Error> {
620        let report = run_diagnostics(&self.endpoint).await?;
621        if send {
622            let (tx, rx) = oneshot::channel();
623            self.message_channel
624                .send(ClientActorMessage::PutNetworkDiagnostics {
625                    done: tx,
626                    report: Box::new(report.clone()),
627                })
628                .await?;
629            rx.await??;
630        }
631
632        Ok(report)
633    }
634}
635
636enum ClientActorMessage {
637    SendMetrics {
638        done: oneshot::Sender<Result<(), Error>>,
639    },
640    Ping {
641        done: oneshot::Sender<Result<Pong, Error>>,
642    },
643    GrantCap {
644        // boxed to avoid large enum variants
645        cap: Box<Rcan<Caps>>,
646        done: oneshot::Sender<Result<(), Error>>,
647    },
648    PutNetworkDiagnostics {
649        report: Box<DiagnosticsReport>,
650        done: oneshot::Sender<Result<(), Error>>,
651    },
652    ReadName {
653        done: oneshot::Sender<Option<String>>,
654    },
655    ReadGroup {
656        done: oneshot::Sender<Option<String>>,
657    },
658    NameEndpoint {
659        name: String,
660        done: oneshot::Sender<Result<(), Error>>,
661    },
662    SetGroup {
663        group: String,
664        done: oneshot::Sender<Result<(), Error>>,
665    },
666    SetAttributes {
667        attributes: BTreeMap<String, String>,
668        done: oneshot::Sender<Result<(), Error>>,
669    },
670    SetAttribute {
671        key: String,
672        value: String,
673        // Carries the full client `Error` (not just `RemoteError`) because the
674        // merged-set validation happens in the actor, where the current set is
675        // known, and can fail with a local `InvalidAttributes` error.
676        done: oneshot::Sender<Result<(), Error>>,
677    },
678}
679
680/// Whether an rpc error means the connection can no longer carry requests.
681///
682/// Most of these are the stream pair or the connection itself failing, so the
683/// connection has to go. An oversized message is the exception: irpc rejects it
684/// locally before anything reaches the wire, so the connection is still fine.
685/// Re-dialing on it would pay for a handshake and a full metrics schema resend
686/// only to hit the same limit on the retry.
687fn is_connection_lost(err: &irpc::Error) -> bool {
688    !matches!(
689        err,
690        irpc::Error::Send {
691            source: irpc::channel::SendError::MaxMessageSizeExceeded { .. },
692            ..
693        }
694    )
695}
696
697/// An irpc client bound to a single authenticated connection.
698///
699/// The server accepts requests only after an `Auth` as the very first
700/// request on a connection, so [`RpcClient::connect`] dials and
701/// authenticates in one step; a value of this type never exists
702/// unauthenticated.
703struct RpcClient {
704    /// Kept alongside the irpc client to detect a remote close.
705    connection: Connection,
706    irpc: IrohServicesClient,
707}
708
709impl RpcClient {
710    /// Dials the remote and authenticates as the connection's first request.
711    async fn connect(
712        endpoint: &Endpoint,
713        remote: EndpointAddr,
714        caps: Rcan<Caps>,
715    ) -> Result<Self, Error> {
716        trace!("client connecting and authorizing");
717        let connection = endpoint
718            .connect(remote, ALPN)
719            .await
720            .inspect_err(|err| debug!("connect failed: {err:?}"))?;
721        let irpc = IrohServicesClient::boxed(IrohRemoteConnection::new(connection.clone()));
722        irpc.rpc(Auth { caps })
723            .await
724            .inspect_err(|err| debug!("authorization failed: {err:?}"))
725            .map_err(|err| RemoteError::AuthError(err.to_string()))?;
726        Ok(Self { connection, irpc })
727    }
728}
729
730struct ClientActor {
731    capabilities: Rcan<Caps>,
732    endpoint: Endpoint,
733    remote: EndpointAddr,
734    /// The active authenticated connection, established on demand.
735    ///
736    /// The actor owns the connection lifecycle rather than using a lazy
737    /// reconnecting client: the server requires `Auth` as the first request
738    /// on every connection, so a transparent mid-request reconnect would send
739    /// an unauthenticated request that the server rejects. [`Self::connect`]
740    /// establishes this; [`Self::rpc`] clears it on transport errors so the
741    /// next request re-dials and re-authenticates.
742    client: Option<RpcClient>,
743    name: Option<String>,
744    group: Option<String>,
745    attributes: BTreeMap<String, String>,
746    session_id: Uuid,
747    encoder: Encoder,
748    /// Kept so connect() can rebuild the encoder to re-send the metrics schema.
749    registry: Arc<RwLock<Registry>>,
750}
751
752impl ClientActor {
753    /// Runs the actor until the inbox closes or `shutdown` is cancelled, then
754    /// pushes one final round of metrics.
755    ///
756    /// Cancelling `shutdown` drops whatever [`Self::run_inner`] is awaiting,
757    /// including all in-flight or queued requests. We do this so that a
758    /// pending dial does not hold up shutdown.
759    async fn run(
760        mut self,
761        interval: Option<Duration>,
762        mut inbox: tokio::sync::mpsc::Receiver<ClientActorMessage>,
763        shutdown: CancellationToken,
764    ) {
765        let metrics_enabled = interval.is_some();
766        let shutdown_and_grace_period_expired = async {
767            shutdown.cancelled().await;
768            time::sleep(SHUTDOWN_GRACE).await;
769        };
770
771        let clean_shutdown = tokio::select! {
772            () = self.run_inner(interval, &mut inbox, &shutdown) => true,
773            () = shutdown_and_grace_period_expired => {
774                debug!("shutdown grace elapsed, dropping the request in flight");
775                false
776            }
777        };
778
779        // Flush only over a connection that already exists. Dialing here would
780        // reintroduce the stall that cancelling just avoided, and an endpoint
781        // that never connected has nothing the server could attribute.
782        if clean_shutdown && metrics_enabled && self.is_connected() {
783            match time::timeout(SHUTDOWN_FLUSH_TIMEOUT, self.send_metrics()).await {
784                Ok(Ok(())) => trace!("pushed final metrics on shutdown"),
785                Ok(Err(err)) => debug!(%err, "failed to push final metrics on shutdown"),
786                Err(_) => debug!("final metrics push on shutdown timed out"),
787            }
788        }
789        debug!("client actor shut down");
790    }
791
792    async fn run_inner(
793        &mut self,
794        interval: Option<Duration>,
795        inbox: &mut tokio::sync::mpsc::Receiver<ClientActorMessage>,
796        shutdown: &CancellationToken,
797    ) {
798        let mut metrics_timer = interval.map(|interval| time::interval(interval));
799        trace!("starting client actor");
800
801        // Send the initial metadata (set via the builder) once the actor starts.
802        // These live on `self`; a send failure here is logged, not fatal.
803        if let Some(name) = self.name.clone()
804            && let Err(err) = self.send_name_endpoint(name).await
805        {
806            warn!(err = %err, "failed setting endpoint name on startup");
807        }
808
809        if let Some(group) = self.group.clone()
810            && let Err(err) = self.send_set_group(group).await
811        {
812            warn!(err = %err, "failed setting endpoint group on startup");
813        }
814
815        if !self.attributes.is_empty()
816            && let Err(err) = self.send_set_attributes(self.attributes.clone()).await
817        {
818            warn!(err = %err, "failed setting endpoint attributes on startup");
819        }
820
821        loop {
822            trace!("client actor tick");
823            tokio::select! {
824                biased;
825                // Shutdown is only observed between requests: a branch body below
826                // runs to completion, so a request already in flight finishes first.
827                () = shutdown.cancelled() => {
828                    trace!("client actor observed shutdown between requests");
829                    break;
830                }
831                Some(msg) = inbox.recv() => {
832                    match msg {
833                        ClientActorMessage::Ping { done } => {
834                            let res = self.send_ping().await;
835                            done.send(res).ok();
836                        },
837                        ClientActorMessage::SendMetrics { done } => {
838                            trace!("sending metrics manually triggered");
839                            let res = self.send_metrics().await;
840                            done.send(res).ok();
841                        }
842                        ClientActorMessage::GrantCap { cap, done } => {
843                            let res = self.grant_cap(*cap).await;
844                            done.send(res).ok();
845                        }
846                        ClientActorMessage::ReadName { done } => {
847                            done.send(self.name.clone()).ok();
848                        }
849                        ClientActorMessage::ReadGroup { done } => {
850                            done.send(self.group.clone()).ok();
851                        }
852                        ClientActorMessage::NameEndpoint { name, done } => {
853                            let res = self.send_name_endpoint(name).await;
854                            done.send(res).ok();
855                        }
856                        ClientActorMessage::SetGroup { group, done } => {
857                            let res = self.send_set_group(group).await;
858                            done.send(res).ok();
859                        }
860                        ClientActorMessage::SetAttributes { attributes, done } => {
861                            let res = self.send_set_attributes(attributes).await;
862                            done.send(res).ok();
863                        }
864                        ClientActorMessage::SetAttribute { key, value, done } => {
865                            // Merge into the current set and validate the union:
866                            // adding one valid entry to a valid set can still
867                            // exceed the max entry count, so the single entry
868                            // being valid is not enough.
869                            let mut merged = self.attributes.clone();
870                            merged.insert(key, value);
871                            let res = match validate_attributes(&merged) {
872                                Ok(()) => self.send_set_attributes(merged).await,
873                                Err(err) => Err(Error::from(err)),
874                            };
875                            done.send(res).ok();
876                        }
877                        ClientActorMessage::PutNetworkDiagnostics { report, done } => {
878                            let res = self.put_network_diagnostics(*report).await;
879                            done.send(res).ok();
880                        }
881                    }
882                }
883                _ = async {
884                    if let Some(ref mut timer) = metrics_timer {
885                        timer.tick().await;
886                    } else {
887                        std::future::pending::<()>().await;
888                    }
889                } => {
890                    trace!("metrics send tick");
891                    if let Err(err) = self.send_metrics().await {
892                        debug!("failed to push metrics: {:#?}", err);
893                    }
894                },
895            }
896        }
897    }
898
899    /// Whether a connection is established and still usable.
900    ///
901    /// Distinct from holding a [`RpcClient`]: the remote may have closed the
902    /// connection since, in which case sending over it would have to re-dial.
903    fn is_connected(&self) -> bool {
904        self.client
905            .as_ref()
906            .is_some_and(|client| client.connection.close_reason().is_none())
907    }
908
909    /// Returns the client for the active connection, establishing one if needed.
910    ///
911    /// The server keeps one metrics decoder per connection, so a fresh
912    /// connection also gets a fresh encoder: the next metrics export then
913    /// carries the full schema for the server's fresh decoder.
914    ///
915    /// # Errors
916    ///
917    /// Returns an error when the dial, or the authentication that follows it,
918    /// fails. A connection is stored only once both have succeeded, and a
919    /// connection found closed is cleared before either is attempted, so a
920    /// caller that sees an error has nothing left to clean up.
921    async fn connect(&mut self) -> Result<&IrohServicesClient, Error> {
922        // A connection the remote has closed (server restart, error close)
923        // can't carry requests anymore; drop it and re-dial.
924        if let Some(client) = &self.client
925            && let Some(reason) = client.connection.close_reason()
926        {
927            debug!(%reason, "connection closed by remote, reconnecting");
928            self.client = None;
929        }
930        // Taking the connection out and putting it back lets `insert` hand out
931        // a borrow tied to the value it just stored. Checking `is_none` and
932        // then looking the value up again needs an `expect`, because the
933        // compiler cannot see that the lookup follows the store.
934        let client = match self.client.take() {
935            Some(client) => client,
936            None => {
937                let client = RpcClient::connect(
938                    &self.endpoint,
939                    self.remote.clone(),
940                    self.capabilities.clone(),
941                )
942                .await?;
943                // Recreate the metrics encoder to force sending the schema on the next metrics push.
944                self.encoder = Encoder::new(self.registry.clone());
945                client
946            }
947        };
948        Ok(&self.client.insert(client).irpc)
949    }
950
951    async fn rpc<Req, Res>(&mut self, msg: Req) -> Result<Res, Error>
952    where
953        IrohServicesProtocol: From<Req>,
954        ServicesMessage: From<WithChannels<Req, IrohServicesProtocol>>,
955        Req: Channels<
956                IrohServicesProtocol,
957                Tx = irpc::channel::oneshot::Sender<Res>,
958                Rx = NoReceiver,
959            > + Display,
960        Res: RpcMessage,
961    {
962        trace!(request = %msg, "client actor send request");
963        let client = self.connect().await?;
964        let res = client.rpc(msg).await;
965
966        if let Err(err) = &res
967            && is_connection_lost(err)
968        {
969            // The connection is gone or in an unknown state. Clear the client
970            // so that the next request dials and authenticates.
971            self.client = None;
972        }
973
974        res.inspect_err(|err| warn!("rpc error: {err}"))
975            .map_err(Error::from)
976    }
977
978    async fn send_ping(&mut self) -> Result<Pong, Error> {
979        let req = rand::random();
980        self.rpc(Ping { req_id: req }).await
981    }
982
983    async fn send_name_endpoint(&mut self, name: String) -> Result<(), Error> {
984        self.rpc(NameEndpoint { name: name.clone() }).await??;
985        self.name = Some(name);
986        Ok(())
987    }
988
989    async fn send_set_group(&mut self, group: String) -> Result<(), Error> {
990        self.rpc(SetGroup {
991            group: group.clone(),
992        })
993        .await??;
994        self.group = Some(group);
995        Ok(())
996    }
997
998    async fn send_set_attributes(
999        &mut self,
1000        attributes: BTreeMap<String, String>,
1001    ) -> Result<(), Error> {
1002        self.rpc(SetAttributes {
1003            attributes: attributes.clone(),
1004        })
1005        .await??;
1006        self.attributes = attributes;
1007        Ok(())
1008    }
1009
1010    async fn send_metrics(&mut self) -> Result<(), Error> {
1011        // Connecting replaces the encoder, so it must happen before the
1012        // export: the first update on a fresh connection has to carry the
1013        // schema for the server's fresh decoder.
1014        self.connect().await?;
1015        let update = self.encoder.export();
1016        // let delta = update_delta(&self.latest_ackd_update, &update);
1017        let req = PutMetrics {
1018            session_id: self.session_id,
1019            update,
1020        };
1021        self.rpc(req).await??;
1022        Ok(())
1023    }
1024
1025    async fn grant_cap(&mut self, cap: Rcan<Caps>) -> Result<(), Error> {
1026        self.rpc(GrantCap { cap }).await??;
1027        Ok(())
1028    }
1029
1030    async fn put_network_diagnostics(&mut self, report: DiagnosticsReport) -> Result<(), Error> {
1031        self.rpc(PutNetworkDiagnostics { report }).await??;
1032        Ok(())
1033    }
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038    use std::{
1039        collections::HashMap,
1040        sync::{
1041            Arc, RwLock,
1042            atomic::{AtomicBool, Ordering},
1043        },
1044    };
1045
1046    use iroh::{
1047        Endpoint, EndpointAddr, SecretKey,
1048        endpoint::{Connection, presets},
1049        protocol::{AcceptError, ProtocolHandler, Router},
1050    };
1051    use iroh_metrics::{
1052        Registry,
1053        encoding::{Decoder, Encoder},
1054    };
1055    use irpc::WithChannels;
1056    use irpc_iroh::read_request;
1057    use n0_error::AnyError;
1058    use n0_future::{
1059        task,
1060        time::{self, Duration},
1061    };
1062    use rand::{RngExt, SeedableRng};
1063    use temp_env_vars::temp_env_vars;
1064
1065    use crate::{
1066        Client, ClientBuilder,
1067        api_secret::ApiSecret,
1068        caps::{Cap, Caps, create_api_token_from_secret_key},
1069        client::{
1070            API_SECRET_ENV_VAR_NAME, BuildError, CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH,
1071            CLIENT_ATTRIBUTES_MAX_COUNT, CLIENT_NAME_MAX_LENGTH, Error, ValidateAttributesError,
1072            ValidateNameError, is_connection_lost,
1073        },
1074        protocol::{ALPN, IrohServicesProtocol, Pong, ServicesMessage},
1075    };
1076
1077    /// What the test server recorded about one PutMetrics request.
1078    #[derive(Debug)]
1079    struct SeenUpdate {
1080        has_schema: bool,
1081        decoded_items: usize,
1082    }
1083
1084    /// What the test server recorded, in arrival order.
1085    #[derive(Debug)]
1086    enum Seen {
1087        Metrics(SeenUpdate),
1088        /// A Ping whose response reached the client without the stream failing.
1089        PingAnswered,
1090    }
1091
1092    /// In-process stand-in for the services backend.
1093    ///
1094    /// Mirrors the real server's session rules: the first request on every
1095    /// connection must be Auth, a repeat Auth on a live connection closes it,
1096    /// and the metrics decoder lives per connection. Requests are read one at
1097    /// a time and any error ends the connection, as in the backend, so a
1098    /// client that drops a request mid-flight takes the connection with it.
1099    #[derive(Debug)]
1100    struct TestServer {
1101        seen: tokio::sync::mpsc::UnboundedSender<Seen>,
1102        /// Kills the next connection without answering, simulating a restart.
1103        drop_next: Arc<AtomicBool>,
1104        /// Held before answering a Ping, so a test can catch one in flight.
1105        ping_delay: Duration,
1106    }
1107
1108    impl TestServer {
1109        fn new(seen: tokio::sync::mpsc::UnboundedSender<Seen>) -> Self {
1110            Self {
1111                seen,
1112                drop_next: Arc::new(AtomicBool::new(false)),
1113                ping_delay: Duration::ZERO,
1114            }
1115        }
1116
1117        fn ping_delay(mut self, delay: Duration) -> Self {
1118            self.ping_delay = delay;
1119            self
1120        }
1121
1122        async fn handle_connection(&self, connection: Connection) -> anyhow::Result<()> {
1123            let Some(first_request) = read_request::<IrohServicesProtocol>(&connection).await?
1124            else {
1125                return Ok(());
1126            };
1127            let ServicesMessage::Auth(WithChannels { tx, .. }) = first_request else {
1128                connection.close(400u32.into(), b"Expected initial auth message");
1129                return Ok(());
1130            };
1131            tx.send(()).await?;
1132
1133            let mut decoder = Decoder::default();
1134            loop {
1135                let Ok(Some(request)) = read_request::<IrohServicesProtocol>(&connection).await
1136                else {
1137                    return Ok(());
1138                };
1139                if self.drop_next.swap(false, Ordering::SeqCst) {
1140                    // Simulates a server restart: the connection dies without an
1141                    // answer and takes the per-connection decoder with it.
1142                    connection.close(500u32.into(), b"test restart");
1143                    return Ok(());
1144                }
1145                match request {
1146                    ServicesMessage::Auth(_) => {
1147                        connection.close(400u32.into(), b"Unexpected auth message");
1148                        anyhow::bail!("client re-sent auth on a live connection");
1149                    }
1150                    ServicesMessage::Ping(WithChannels { inner, tx, .. }) => {
1151                        time::sleep(self.ping_delay).await;
1152                        // A client that dropped this request has reset the
1153                        // stream, and this send is where the server finds out.
1154                        tx.send(Pong {
1155                            req_id: inner.req_id,
1156                        })
1157                        .await?;
1158                        let _ = self.seen.send(Seen::PingAnswered);
1159                    }
1160                    ServicesMessage::PutMetrics(WithChannels { inner, tx, .. }) => {
1161                        let has_schema = inner.update.schema.is_some();
1162                        decoder.import(inner.update);
1163                        let _ = self.seen.send(Seen::Metrics(SeenUpdate {
1164                            has_schema,
1165                            decoded_items: decoder.iter().count(),
1166                        }));
1167                        tx.send(Ok(())).await?;
1168                    }
1169                    _ => {
1170                        connection.close(400u32.into(), b"Unexpected message in test");
1171                        anyhow::bail!("unexpected message in test");
1172                    }
1173                }
1174            }
1175        }
1176    }
1177
1178    impl ProtocolHandler for TestServer {
1179        async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
1180            self.handle_connection(connection).await.map_err(|e| {
1181                let boxed: Box<dyn std::error::Error + Send + Sync> = e.into();
1182                AcceptError::from(AnyError::from(boxed))
1183            })
1184        }
1185    }
1186
1187    /// Spawns `server` on its own endpoint and returns a client builder aimed
1188    /// at it, with the router and the client endpoint for teardown.
1189    ///
1190    /// The metrics interval is left to the caller, since that is what the
1191    /// tests vary. The test server accepts any capability, so a self-issued
1192    /// token works.
1193    async fn spawn_test_server(seed: u64, server: TestServer) -> (Router, Endpoint, ClientBuilder) {
1194        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(seed);
1195        let server_ep = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1196        let client_ep = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1197        let router = Router::builder(server_ep.clone())
1198            .accept(ALPN, server)
1199            .spawn();
1200
1201        let shared_secret = SecretKey::from_bytes(&rng.random());
1202        let cap = create_api_token_from_secret_key(
1203            shared_secret,
1204            client_ep.id(),
1205            Duration::from_secs(3600),
1206            Caps::for_shared_secret(),
1207        )
1208        .unwrap();
1209
1210        let builder = Client::builder(&client_ep)
1211            .remote(server_ep.addr())
1212            .rcan(cap)
1213            .unwrap();
1214        (router, client_ep, builder)
1215    }
1216
1217    /// Awaits the next metrics push the server recorded.
1218    async fn next_metrics(rx: &mut tokio::sync::mpsc::UnboundedReceiver<Seen>) -> SeenUpdate {
1219        match rx.recv().await.expect("server dropped the record channel") {
1220            Seen::Metrics(update) => update,
1221            other => panic!("expected a metrics push, recorded {other:?}"),
1222        }
1223    }
1224
1225    /// Takes everything the server has recorded so far, without waiting.
1226    fn recorded_so_far(rx: &mut tokio::sync::mpsc::UnboundedReceiver<Seen>) -> Vec<Seen> {
1227        let mut seen = Vec::new();
1228        while let Ok(record) = rx.try_recv() {
1229            seen.push(record);
1230        }
1231        seen
1232    }
1233
1234    /// A reconnect must make the next metrics update carry the schema.
1235    ///
1236    /// The server holds one decoder per connection, so the first update after
1237    /// a re-auth is undecodable unless the schema comes along again.
1238    #[tokio::test]
1239    async fn test_metrics_schema_resent_after_reconnect() {
1240        let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel();
1241        let server = TestServer::new(seen_tx);
1242        let drop_next = server.drop_next.clone();
1243        let (router, client_ep, builder) = spawn_test_server(2, server).await;
1244
1245        let client = builder.disable_metrics_interval().build().await.unwrap();
1246
1247        // The first export on a fresh session carries the schema.
1248        client.push_metrics().await.unwrap();
1249        let first = next_metrics(&mut seen_rx).await;
1250        assert!(first.has_schema);
1251        assert!(first.decoded_items > 0);
1252
1253        // Steady state stops sending the schema. The endpoint's own metric
1254        // families may still grow for a few exports right after connecting
1255        // (each growth re-publishes the schema), so push until the schema
1256        // settles; the connection's decoder keeps decoding throughout.
1257        let mut settled = false;
1258        for _ in 0..20 {
1259            client.push_metrics().await.unwrap();
1260            let seen = next_metrics(&mut seen_rx).await;
1261            assert!(seen.decoded_items > 0);
1262            if !seen.has_schema {
1263                settled = true;
1264                break;
1265            }
1266        }
1267        assert!(settled, "schema must stop being sent once it is unchanged");
1268
1269        // The server drops the connection mid-request: one failed round trip.
1270        drop_next.store(true, Ordering::SeqCst);
1271        assert!(client.push_metrics().await.is_err());
1272
1273        // The client re-dials and re-auths; the first update on the new
1274        // connection must include the schema for the server's fresh decoder.
1275        client.push_metrics().await.unwrap();
1276        let third = next_metrics(&mut seen_rx).await;
1277        assert!(third.has_schema, "schema must be re-sent after a reconnect");
1278        assert!(third.decoded_items > 0);
1279
1280        router.shutdown().await.unwrap();
1281        client_ep.close().await;
1282    }
1283
1284    /// Documents the encoder and decoder contract the reconnect fix relies on.
1285    #[tokio::test]
1286    async fn test_fresh_encoder_resends_schema() {
1287        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1288        let mut registry = Registry::default();
1289        registry.register_all(endpoint.metrics());
1290        let registry = Arc::new(RwLock::new(registry));
1291
1292        let mut encoder = Encoder::new(registry.clone());
1293        let first = encoder.export();
1294        assert!(first.schema.is_some());
1295
1296        // Steady state omits the schema once it has been exported.
1297        let schemaless = encoder.export();
1298        assert!(schemaless.schema.is_none());
1299
1300        // A decoder that never saw the schema decodes such an update to
1301        // nothing: this is the server side of the reconnect bug.
1302        let mut fresh_decoder = Decoder::default();
1303        fresh_decoder.import(schemaless);
1304        assert_eq!(fresh_decoder.iter().count(), 0);
1305
1306        // A new encoder over the same registry starts at schema version zero
1307        // and re-publishes the schema, which is what auth() does on re-auth.
1308        let mut encoder = Encoder::new(registry);
1309        let resent = encoder.export();
1310        assert!(resent.schema.is_some());
1311        let mut fresh_decoder = Decoder::default();
1312        fresh_decoder.import(resent);
1313        assert!(fresh_decoder.iter().count() > 0);
1314    }
1315
1316    #[tokio::test]
1317    #[temp_env_vars]
1318    async fn test_api_key_from_env() {
1319        // construct
1320        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(0);
1321        let shared_secret = SecretKey::from_bytes(&rng.random());
1322        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1323        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
1324        unsafe {
1325            std::env::set_var(API_SECRET_ENV_VAR_NAME, api_secret.to_string());
1326        };
1327
1328        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1329
1330        let builder = Client::builder(&endpoint).api_secret_from_env().unwrap();
1331
1332        let fake_endpoint_addr: EndpointAddr = fake_endpoint_id.into();
1333        assert_eq!(builder.remote, Some(fake_endpoint_addr));
1334
1335        // Compare capability fields individually to avoid flaky timestamp
1336        // mismatches between the builder's rcan and a freshly-created one.
1337        let cap = builder.cap.as_ref().expect("expected capability to be set");
1338        assert_eq!(cap.capability(), &Caps::new([Cap::Client]));
1339        assert_eq!(cap.audience(), &endpoint.id().as_verifying_key());
1340        assert_eq!(cap.issuer(), &shared_secret.public().as_verifying_key());
1341    }
1342
1343    /// Assert that disabling metrics interval can manually send metrics without
1344    /// panicking. Metrics sending itself is expected to fail.
1345    #[tokio::test]
1346    async fn test_no_metrics_interval() {
1347        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(1);
1348        let shared_secret = SecretKey::from_bytes(&rng.random());
1349        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1350        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
1351
1352        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1353
1354        let client = Client::builder(&endpoint)
1355            .disable_metrics_interval()
1356            .api_secret(api_secret)
1357            .unwrap()
1358            .build()
1359            .await
1360            .unwrap();
1361
1362        let err = client.push_metrics().await;
1363        assert!(err.is_err());
1364    }
1365
1366    /// Assert that shutdown returns even when the final metrics push fails,
1367    /// and that the actor is stopped afterwards.
1368    #[tokio::test]
1369    async fn test_shutdown_stops_actor() {
1370        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(2);
1371        let shared_secret = SecretKey::from_bytes(&rng.random());
1372        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1373        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
1374
1375        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1376
1377        let client = Client::builder(&endpoint)
1378            .api_secret(api_secret)
1379            .unwrap()
1380            .build()
1381            .await
1382            .unwrap();
1383
1384        client.shutdown().await;
1385
1386        // the actor is gone, so requests fail
1387        let err = client.push_metrics().await;
1388        assert!(err.is_err());
1389    }
1390
1391    /// An oversized message never reaches the wire, so it must not cost the
1392    /// connection: re-dialing would pay for a handshake and a full metrics
1393    /// schema resend and then hit the same limit again.
1394    #[test]
1395    fn test_oversized_message_keeps_the_connection() {
1396        let send_error = |source| irpc::Error::Send {
1397            source,
1398            meta: Default::default(),
1399        };
1400
1401        assert!(!is_connection_lost(&send_error(
1402            irpc::channel::SendError::MaxMessageSizeExceeded {
1403                meta: Default::default(),
1404            }
1405        )));
1406        assert!(is_connection_lost(&send_error(
1407            irpc::channel::SendError::ReceiverClosed {
1408                meta: Default::default(),
1409            }
1410        )));
1411    }
1412
1413    /// Assert that shutting down with a request in flight lets that request
1414    /// finish, so the final metrics push still reaches the server.
1415    ///
1416    /// Dropping the request instead would reset its stream, and the server
1417    /// treats that as the connection failing: it stops reading, so the push
1418    /// that shutdown exists for is never seen.
1419    #[tokio::test]
1420    async fn test_shutdown_drains_request_in_flight() {
1421        let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel();
1422        let (router, client_ep, builder) = spawn_test_server(
1423            9,
1424            // long enough for shutdown to land while a ping is in flight
1425            TestServer::new(seen_tx).ping_delay(Duration::from_millis(300)),
1426        )
1427        .await;
1428
1429        let client = builder
1430            // long enough that only the startup tick fires on its own
1431            .metrics_interval(Duration::from_secs(3600))
1432            .build()
1433            .await
1434            .unwrap();
1435
1436        // the startup push is what establishes the connection
1437        next_metrics(&mut seen_rx).await;
1438
1439        // put a ping in flight, then shut down while the server sits on it
1440        let pinging = client.clone();
1441        let ping = task::spawn(async move { pinging.ping().await });
1442        time::sleep(Duration::from_millis(100)).await;
1443        client.shutdown().await;
1444
1445        let recorded = recorded_so_far(&mut seen_rx);
1446        assert!(
1447            recorded
1448                .iter()
1449                .any(|seen| matches!(seen, Seen::PingAnswered)),
1450            "the ping in flight was dropped, so the server saw its stream fail"
1451        );
1452        assert!(
1453            recorded.iter().any(|seen| matches!(seen, Seen::Metrics(_))),
1454            "the final metrics push did not reach the server"
1455        );
1456
1457        let _ = ping.await;
1458        router.shutdown().await.unwrap();
1459        client_ep.close().await;
1460    }
1461
1462    /// Assert that shutdown does not wait for a request in flight.
1463    ///
1464    /// The metrics interval fires as soon as the actor starts, so with an
1465    /// unreachable remote the actor is inside a dial that runs for about a
1466    /// minute. Shutting down has to cancel that dial rather than queue behind
1467    /// it.
1468    #[tokio::test]
1469    async fn test_shutdown_cancels_dial_in_flight() {
1470        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(3);
1471        let shared_secret = SecretKey::from_bytes(&rng.random());
1472        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1473        let api_secret = ApiSecret::new(shared_secret, fake_endpoint_id);
1474
1475        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1476
1477        let client = Client::builder(&endpoint)
1478            .api_secret(api_secret)
1479            .unwrap()
1480            // TEST-NET-1, routable but silently dropped, so the dial hangs
1481            // instead of failing fast the way an address-less remote would.
1482            .remote(
1483                EndpointAddr::new(fake_endpoint_id).with_ip_addr("192.0.2.1:1234".parse().unwrap()),
1484            )
1485            .build()
1486            .await
1487            .unwrap();
1488
1489        // let the startup metrics tick get as far as the dial
1490        time::sleep(Duration::from_millis(200)).await;
1491
1492        time::timeout(Duration::from_secs(5), client.shutdown())
1493            .await
1494            .expect("shutdown blocked on the dial in flight");
1495    }
1496
1497    #[tokio::test]
1498    async fn test_name() {
1499        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(0);
1500        let shared_secret = SecretKey::from_bytes(&rng.random());
1501        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1502        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
1503
1504        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1505
1506        let builder = Client::builder(&endpoint)
1507            .name("my-node 👋")
1508            .unwrap()
1509            .api_secret(api_secret)
1510            .unwrap();
1511
1512        assert_eq!(builder.name, Some("my-node 👋".to_string()));
1513
1514        let Err(err) = Client::builder(&endpoint).name("a") else {
1515            panic!("name should fail for strings under 2 bytes");
1516        };
1517        assert!(matches!(
1518            err.downcast_ref::<BuildError>(),
1519            Some(BuildError::InvalidName(ValidateNameError::TooShort))
1520        ));
1521
1522        let too_long_name = "👋".repeat(129);
1523        let Err(err) = Client::builder(&endpoint).name(&too_long_name) else {
1524            panic!("name should fail for strings over 128 bytes");
1525        };
1526        assert!(matches!(
1527            err.downcast_ref::<BuildError>(),
1528            Some(BuildError::InvalidName(ValidateNameError::TooLong))
1529        ));
1530    }
1531
1532    #[tokio::test]
1533    async fn test_group() {
1534        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(0);
1535        let shared_secret = SecretKey::from_bytes(&rng.random());
1536        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1537        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
1538
1539        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1540
1541        let builder = Client::builder(&endpoint)
1542            .group("staging")
1543            .unwrap()
1544            .api_secret(api_secret)
1545            .unwrap();
1546
1547        assert_eq!(builder.group, Some("staging".to_string()));
1548
1549        let Err(err) = Client::builder(&endpoint).group("a") else {
1550            panic!("group should fail for strings under 2 bytes");
1551        };
1552        assert!(matches!(
1553            err.downcast_ref::<BuildError>(),
1554            Some(BuildError::InvalidGroup(ValidateNameError::TooShort))
1555        ));
1556
1557        let too_long_group = "👋".repeat(129);
1558        let Err(err) = Client::builder(&endpoint).group(&too_long_group) else {
1559            panic!("group should fail for strings over 128 bytes");
1560        };
1561        assert!(matches!(
1562            err.downcast_ref::<BuildError>(),
1563            Some(BuildError::InvalidGroup(ValidateNameError::TooLong))
1564        ));
1565    }
1566
1567    #[tokio::test]
1568    async fn test_attributes() {
1569        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1570
1571        // empty iterator is accepted (clears attributes server-side)
1572        let builder = Client::builder(&endpoint)
1573            .attributes(std::iter::empty::<(String, String)>())
1574            .unwrap();
1575        assert_eq!(builder.attributes.as_ref().map(|m| m.len()), Some(0));
1576
1577        // array literal of `&str` tuples, for the one-liner ergonomics
1578        let builder = Client::builder(&endpoint)
1579            .attributes([("env", "prod"), ("region", "us-west")])
1580            .unwrap();
1581        let attrs = builder.attributes.as_ref().expect("attributes set");
1582        assert_eq!(attrs.get("env").map(String::as_str), Some("prod"));
1583        assert_eq!(attrs.get("region").map(String::as_str), Some("us-west"));
1584
1585        // HashMap<String, String> also works
1586        let mut map: HashMap<String, String> = HashMap::new();
1587        map.insert("k1".into(), "v1".into());
1588        map.insert("k2".into(), "".into()); // empty value is allowed
1589        let builder = Client::builder(&endpoint).attributes(map).unwrap();
1590        let attrs = builder.attributes.as_ref().expect("attributes set");
1591        assert_eq!(attrs.get("k2").map(String::as_str), Some(""));
1592
1593        // value over 128 bytes errors
1594        let too_long_value = "x".repeat(129);
1595        let Err(err) = Client::builder(&endpoint).attributes([("ok", too_long_value.as_str())])
1596        else {
1597            panic!("attributes should fail for value over 128 bytes");
1598        };
1599        assert!(matches!(
1600            err.downcast_ref::<BuildError>(),
1601            Some(BuildError::InvalidAttributes(
1602                ValidateAttributesError::ValueTooLong
1603            ))
1604        ));
1605
1606        // key under 2 bytes errors
1607        let Err(err) = Client::builder(&endpoint).attributes([("a", "v")]) else {
1608            panic!("attributes should fail for key under 2 bytes");
1609        };
1610        assert!(matches!(
1611            err.downcast_ref::<BuildError>(),
1612            Some(BuildError::InvalidAttributes(
1613                ValidateAttributesError::InvalidKey(ValidateNameError::TooShort)
1614            ))
1615        ));
1616
1617        // more than 128 entries errors
1618        let big: Vec<(String, String)> = (0..(CLIENT_ATTRIBUTES_MAX_COUNT + 1))
1619            .map(|i| (format!("key_{i:04}"), format!("val_{i}")))
1620            .collect();
1621        let Err(err) = Client::builder(&endpoint).attributes(big) else {
1622            panic!("attributes should fail for more than 128 entries");
1623        };
1624        assert!(matches!(
1625            err.downcast_ref::<BuildError>(),
1626            Some(BuildError::InvalidAttributes(
1627                ValidateAttributesError::TooManyEntries
1628            ))
1629        ));
1630    }
1631
1632    /// Build a client with no reachable server, mirroring `test_no_metrics_interval`.
1633    /// The runtime setters validate input locally before any network call, so
1634    /// validation errors surface without a live server.
1635    async fn build_serverless_client(seed: u64) -> Client {
1636        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(seed);
1637        let shared_secret = SecretKey::from_bytes(&rng.random());
1638        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1639        let api_secret = ApiSecret::new(shared_secret, fake_endpoint_id);
1640
1641        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1642
1643        Client::builder(&endpoint)
1644            .disable_metrics_interval()
1645            .api_secret(api_secret)
1646            .unwrap()
1647            .build()
1648            .await
1649            .unwrap()
1650    }
1651
1652    /// Covers the runtime `Client::set_group` path the builder tests miss:
1653    /// validation runs locally and returns `Error::InvalidGroup` without a server.
1654    #[tokio::test]
1655    async fn test_set_group_runtime_validation() {
1656        let client = build_serverless_client(2).await;
1657
1658        let err = client
1659            .set_group("a")
1660            .await
1661            .expect_err("too-short group should fail validation");
1662        assert!(matches!(
1663            err,
1664            Error::InvalidGroup(ValidateNameError::TooShort)
1665        ));
1666
1667        let too_long = "x".repeat(CLIENT_NAME_MAX_LENGTH + 1);
1668        let err = client
1669            .set_group(too_long)
1670            .await
1671            .expect_err("too-long group should fail validation");
1672        assert!(matches!(
1673            err,
1674            Error::InvalidGroup(ValidateNameError::TooLong)
1675        ));
1676    }
1677
1678    /// Covers the runtime `Client::set_attributes` path the builder tests miss:
1679    /// validation runs locally and returns `Error::InvalidAttributes` without a server.
1680    #[tokio::test]
1681    async fn test_set_attributes_runtime_validation() {
1682        let client = build_serverless_client(3).await;
1683
1684        // key under 2 bytes
1685        let err = client
1686            .set_attributes([("a", "v")])
1687            .await
1688            .expect_err("too-short attribute key should fail validation");
1689        assert!(matches!(
1690            err,
1691            Error::InvalidAttributes(ValidateAttributesError::InvalidKey(
1692                ValidateNameError::TooShort
1693            ))
1694        ));
1695
1696        // value over the max length
1697        let too_long_value = "x".repeat(CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH + 1);
1698        let err = client
1699            .set_attributes([("ok", too_long_value.as_str())])
1700            .await
1701            .expect_err("too-long attribute value should fail validation");
1702        assert!(matches!(
1703            err,
1704            Error::InvalidAttributes(ValidateAttributesError::ValueTooLong)
1705        ));
1706
1707        // more entries than allowed
1708        let big: Vec<(String, String)> = (0..(CLIENT_ATTRIBUTES_MAX_COUNT + 1))
1709            .map(|i| (format!("key_{i:04}"), format!("val_{i}")))
1710            .collect();
1711        let err = client
1712            .set_attributes(big)
1713            .await
1714            .expect_err("too many attributes should fail validation");
1715        assert!(matches!(
1716            err,
1717            Error::InvalidAttributes(ValidateAttributesError::TooManyEntries)
1718        ));
1719    }
1720
1721    #[tokio::test]
1722    async fn test_set_attribute_runtime_validation() {
1723        let client = build_serverless_client(7).await;
1724
1725        // A bad single key is rejected before any network call.
1726        let err = client
1727            .set_attribute("a", "v")
1728            .await
1729            .expect_err("too-short attribute key should fail validation");
1730        assert!(matches!(
1731            err,
1732            Error::InvalidAttributes(ValidateAttributesError::InvalidKey(
1733                ValidateNameError::TooShort
1734            ))
1735        ));
1736
1737        // A valid single attribute passes validation, then reaches the dial
1738        // (no server) and surfaces a connect error, proving set_attribute
1739        // is wired through the actor/RPC path.
1740        let err = client
1741            .set_attribute("firmware", "2.1.0")
1742            .await
1743            .expect_err("no server: remote call must fail after validation passes");
1744        assert!(matches!(err, Error::Connect(_)), "got {err:?}");
1745    }
1746
1747    #[tokio::test]
1748    async fn test_set_attribute_merge_over_limit_rejected() {
1749        // A client already holding the maximum number of attributes.
1750        let full: Vec<(String, String)> = (0..CLIENT_ATTRIBUTES_MAX_COUNT)
1751            .map(|i| (format!("key_{i:04}"), "v".to_string()))
1752            .collect();
1753
1754        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(9);
1755        let shared_secret = SecretKey::from_bytes(&rng.random());
1756        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1757        let api_secret = ApiSecret::new(shared_secret, fake_endpoint_id);
1758        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1759        let client = Client::builder(&endpoint)
1760            .disable_metrics_interval()
1761            .attributes(full)
1762            .unwrap()
1763            .api_secret(api_secret)
1764            .unwrap()
1765            .build()
1766            .await
1767            .unwrap();
1768
1769        // Merging one more (individually valid) entry pushes the set over the
1770        // limit. The single-entry check would miss this; the merged-set check in
1771        // the actor catches it locally, before any network call.
1772        let err = client
1773            .set_attribute("one-too-many", "v")
1774            .await
1775            .expect_err("merging past the attribute limit must fail");
1776        assert!(
1777            matches!(
1778                err,
1779                Error::InvalidAttributes(ValidateAttributesError::TooManyEntries)
1780            ),
1781            "expected TooManyEntries, got {err:?}"
1782        );
1783    }
1784
1785    /// Boundary "accepted" case for the runtime setter. Without a live server we
1786    /// cannot assert success; instead we assert the input passes local validation
1787    /// and the call proceeds to the (failing) dial, surfacing `Error::Connect`
1788    /// rather than an `Error::InvalidAttributes` validation error.
1789    #[tokio::test]
1790    async fn test_set_attributes_runtime_boundary_accepted() {
1791        let client = build_serverless_client(4).await;
1792
1793        // value of exactly the max length is accepted by validation
1794        let max_value = "x".repeat(CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH);
1795        let err = client
1796            .set_attributes([("ok".to_string(), max_value)])
1797            .await
1798            .expect_err("no server: remote call must fail after validation passes");
1799        assert!(
1800            matches!(err, Error::Connect(_)),
1801            "expected a connect error (validation accepted), got {err:?}"
1802        );
1803
1804        // exactly CLIENT_ATTRIBUTES_MAX_COUNT entries is accepted by validation
1805        let max_entries: Vec<(String, String)> = (0..CLIENT_ATTRIBUTES_MAX_COUNT)
1806            .map(|i| (format!("key_{i:04}"), format!("val_{i}")))
1807            .collect();
1808        let err = client
1809            .set_attributes(max_entries)
1810            .await
1811            .expect_err("no server: remote call must fail after validation passes");
1812        assert!(
1813            matches!(err, Error::Connect(_)),
1814            "expected a connect error (validation accepted), got {err:?}"
1815        );
1816    }
1817}