Skip to main content

iroh_services/
protocol.rs

1use std::collections::BTreeMap;
2
3use anyhow::Result;
4use irpc::{channel::oneshot, rpc_requests};
5use rcan::Rcan;
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::{caps::Caps, net_diagnostics::DiagnosticsReport};
10
11/// The main ALPN for connecting from the client to the cloud node.
12///
13/// # Versioning
14///
15/// The wire protocol is append-only and does not bump this ALPN for additive
16/// changes. postcard encodes enum variants by index, so as long as new
17/// [`IrohServicesProtocol`] and [`RemoteError`] variants are only appended
18/// (never inserted, reordered, or removed), older messages stay wire-compatible:
19///
20/// - An older client always works against a newer server: the server decodes
21///   every request the client can send, and only replies with error variants the
22///   client already knows.
23/// - A newer client against an older server keeps working for the pre-existing
24///   requests (auth, metrics, and so on); a request the old server does not know
25///   fails as a per-request error rather than breaking the connection.
26///
27/// The cloud node is deployed at or ahead of the clients that talk to it, so the
28/// second case is transient and limited to the new calls. A breaking change
29/// (reordering or removing variants, or changing a message's shape) requires a
30/// new ALPN.
31pub const ALPN: &[u8] = b"/iroh/n0des/1";
32
33pub type IrohServicesClient = irpc::Client<IrohServicesProtocol>;
34
35/// New request variants MUST be appended, never inserted or reordered. See the
36/// versioning policy on [`ALPN`].
37#[rpc_requests(message = ServicesMessage)]
38#[derive(Debug, Serialize, Deserialize)]
39#[allow(clippy::large_enum_variant)]
40#[non_exhaustive]
41pub enum IrohServicesProtocol {
42    #[rpc(tx=oneshot::Sender<()>)]
43    Auth(Auth),
44    #[rpc(tx=oneshot::Sender<RemoteResult<()>>)]
45    PutMetrics(PutMetrics),
46    #[rpc(tx=oneshot::Sender<Pong>)]
47    Ping(Ping),
48
49    #[rpc(tx=oneshot::Sender<RemoteResult<()>>)]
50    PutNetworkDiagnostics(PutNetworkDiagnostics),
51
52    #[rpc(tx=oneshot::Sender<RemoteResult<()>>)]
53    GrantCap(GrantCap),
54
55    #[rpc(tx=oneshot::Sender<RemoteResult<()>>)]
56    NameEndpoint(NameEndpoint),
57
58    #[rpc(tx=oneshot::Sender<RemoteResult<()>>)]
59    SetGroup(SetGroup),
60
61    #[rpc(tx=oneshot::Sender<RemoteResult<()>>)]
62    SetAttributes(SetAttributes),
63}
64
65/// Dedicated protocol for cloud-to-endpoint net diagnostics connections.
66#[rpc_requests(message = NetDiagnosticsMessage)]
67#[derive(Debug, Serialize, Deserialize)]
68#[allow(clippy::large_enum_variant)]
69pub enum ClientHostProtocol {
70    #[rpc(tx=oneshot::Sender<()>)]
71    Auth(Auth),
72    #[rpc(tx=oneshot::Sender<RemoteResult<DiagnosticsReport>>)]
73    RunNetworkDiagnostics(RunNetworkDiagnostics),
74}
75
76pub type RemoteResult<T> = Result<T, RemoteError>;
77
78#[derive(Clone, Serialize, Deserialize, thiserror::Error, Debug)]
79#[non_exhaustive]
80pub enum RemoteError {
81    // The first three variants and their order are the v1 wire contract: postcard
82    // encodes enum variants by index, so a v1 client only decodes these and at
83    // their original positions. New variants MUST be appended after them, and the
84    // server must only send new variants in response to new (v2+) requests.
85    #[error("Missing capability: {}", _0.to_strings().join(", "))]
86    MissingCapability(Caps),
87    #[error("Unauthorized: {}", _0)]
88    AuthError(String),
89    #[error("Internal server error")]
90    InternalServerError,
91    #[error("Invalid input: {}", _0)]
92    InvalidInput(String),
93    #[error("Rate limit exceeded")]
94    RateLimited,
95}
96
97/// Authentication on first request
98#[derive(Debug, derive_more::Display, Serialize, Deserialize)]
99#[display("Auth")]
100pub struct Auth {
101    pub caps: Rcan<Caps>,
102}
103
104/// Request to store the given metrics data
105#[derive(Debug, derive_more::Display, Serialize, Deserialize)]
106#[display("PutMetrics")]
107pub struct PutMetrics {
108    pub session_id: Uuid,
109    pub update: iroh_metrics::encoding::Update,
110}
111
112/// Simple ping requests
113#[derive(Debug, derive_more::Display, Serialize, Deserialize)]
114#[display("Ping")]
115pub struct Ping {
116    pub req_id: [u8; 16],
117}
118
119/// Simple ping response
120#[derive(Debug, Serialize, Deserialize)]
121pub struct Pong {
122    pub req_id: [u8; 16],
123}
124
125/// Publishing network diagnostics
126#[derive(Debug, derive_more::Display, Serialize, Deserialize)]
127#[display("PutNetworkDiagnostics")]
128pub struct PutNetworkDiagnostics {
129    pub report: crate::net_diagnostics::DiagnosticsReport,
130}
131
132/// ask this node to run diagnostics & return the result.
133/// present even without the net_diagnostics feature flag because the request
134/// struct is empty in both cases
135#[derive(Debug, derive_more::Display, Serialize, Deserialize)]
136#[display("RunNetworkDiagnostics")]
137pub struct RunNetworkDiagnostics;
138
139/// Grant a capability token to the remote endpoint. The remote should store
140/// the RCAN and use it when dialing back to authorize its requests.
141#[derive(Debug, derive_more::Display, Serialize, Deserialize)]
142#[display("GrantCap")]
143pub struct GrantCap {
144    pub cap: Rcan<Caps>,
145}
146
147/// Label the client endpoint cloud-side with a string identifier.
148#[derive(Debug, derive_more::Display, Serialize, Deserialize)]
149#[display("NameEndpoint")]
150pub struct NameEndpoint {
151    pub name: String,
152}
153
154/// Attach the client endpoint to a single named group cloud-side.
155#[derive(Debug, derive_more::Display, Serialize, Deserialize)]
156#[display("SetGroup")]
157pub struct SetGroup {
158    pub group: String,
159}
160
161/// Replace the arbitrary key-value attributes on the client endpoint cloud-side.
162#[derive(Debug, derive_more::Display, Serialize, Deserialize)]
163#[display("SetAttributes")]
164pub struct SetAttributes {
165    pub attributes: BTreeMap<String, String>,
166}
167
168#[cfg(test)]
169mod tests {
170    use std::collections::BTreeMap;
171
172    use super::{RemoteError, SetAttributes, SetGroup};
173    use crate::client::CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH;
174
175    #[test]
176    fn test_remote_error_wire_compat() {
177        // postcard encodes enum variants by their index. v1 clients only know
178        // the first three RemoteError variants, so these indices are a frozen
179        // wire contract; new variants must be appended after them.
180        let idx = |e: &RemoteError| postcard::to_stdvec(e).expect("encode")[0];
181        assert_eq!(idx(&RemoteError::AuthError(String::new())), 1);
182        assert_eq!(idx(&RemoteError::InternalServerError), 2);
183        // v2+ variants, appended after the v1 set.
184        assert_eq!(idx(&RemoteError::InvalidInput(String::new())), 3);
185        assert_eq!(idx(&RemoteError::RateLimited), 4);
186    }
187
188    // The wire format used by irpc (and elsewhere in this crate, see
189    // `api_secret.rs`) is postcard. These round-trips pin the on-the-wire
190    // contract these messages share with the server.
191
192    #[test]
193    fn test_set_group_serde_roundtrip() {
194        // a normal group, plus a unicode group for good measure
195        for group in ["staging", "my-group 👋"] {
196            let msg = SetGroup {
197                group: group.to_string(),
198            };
199            let bytes = postcard::to_stdvec(&msg).expect("postcard serialize");
200            let decoded: SetGroup = postcard::from_bytes(&bytes).expect("postcard deserialize");
201            assert_eq!(decoded.group, msg.group);
202        }
203    }
204
205    #[test]
206    fn test_set_attributes_serde_roundtrip() {
207        // empty map: the documented "clear" case
208        let empty = SetAttributes {
209            attributes: BTreeMap::new(),
210        };
211        let bytes = postcard::to_stdvec(&empty).expect("postcard serialize");
212        let decoded: SetAttributes = postcard::from_bytes(&bytes).expect("postcard deserialize");
213        assert!(decoded.attributes.is_empty());
214        assert_eq!(decoded.attributes, empty.attributes);
215
216        // unicode key/value plus a value at exactly the documented max length
217        let mut attributes = BTreeMap::new();
218        attributes.insert("région 🌍".to_string(), "us-wëst 🚀".to_string());
219        let max_value = "x".repeat(CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH);
220        assert_eq!(max_value.len(), CLIENT_ATTRIBUTE_VALUE_MAX_LENGTH);
221        attributes.insert("max".to_string(), max_value);
222
223        let msg = SetAttributes { attributes };
224        let bytes = postcard::to_stdvec(&msg).expect("postcard serialize");
225        let decoded: SetAttributes = postcard::from_bytes(&bytes).expect("postcard deserialize");
226        assert_eq!(decoded.attributes, msg.attributes);
227    }
228}