Skip to main content

iroh_services/
client_host.rs

1use anyhow::{Result, ensure};
2use iroh::{
3    Endpoint, EndpointId,
4    endpoint::Connection,
5    protocol::{AcceptError, ProtocolHandler},
6};
7use irpc::WithChannels;
8use irpc_iroh::read_request;
9use n0_error::AnyError;
10use rcan::{Capability, CapabilityOrigin, Rcan};
11use tracing::{debug, warn};
12
13use crate::{
14    caps::{Caps, NetDiagnosticsCap},
15    protocol::{ClientHostProtocol, NetDiagnosticsMessage, RemoteError},
16};
17
18/// The ALPN for sending messages from the cloud node to the client.
19pub const CLIENT_HOST_ALPN: &[u8] = b"n0/n0des-client-host/1";
20
21pub type ClientHostClient = irpc::Client<ClientHostProtocol>;
22
23/// Protocol handler for cloud-to-endpoint connections.
24#[derive(Debug)]
25pub struct ClientHost {
26    endpoint: Endpoint,
27}
28
29impl ProtocolHandler for ClientHost {
30    async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
31        self.handle_connection(connection).await.map_err(|e| {
32            let boxed: Box<dyn std::error::Error + Send + Sync> = e.into();
33            AcceptError::from(AnyError::from(boxed))
34        })
35    }
36}
37
38impl ClientHost {
39    pub fn new(endpoint: &Endpoint) -> Self {
40        Self {
41            endpoint: endpoint.clone(),
42        }
43    }
44
45    async fn handle_connection(&self, connection: Connection) -> Result<()> {
46        let remote_node_id = connection.remote_id();
47        let Some(first_request) = read_request::<ClientHostProtocol>(&connection).await? else {
48            return Ok(());
49        };
50
51        let NetDiagnosticsMessage::Auth(WithChannels { inner, tx, .. }) = first_request else {
52            debug!(remote_node_id = %remote_node_id.fmt_short(), "Expected initial auth message");
53            connection.close(400u32.into(), b"Expected initial auth message");
54            return Ok(());
55        };
56        let rcan = inner.caps;
57        let capability = rcan.capability();
58
59        let res = verify_rcan(&self.endpoint, remote_node_id, &rcan);
60        match res {
61            Ok(()) => tx.send(()).await?,
62            Err(err) => {
63                warn!("authentication failed: {err:?}");
64                connection.close(401u32.into(), b"Unauthorized");
65                return Ok(());
66            }
67        }
68
69        // Read exactly one RunNetworkDiagnostics request
70        let Some(request) = read_request::<ClientHostProtocol>(&connection).await? else {
71            return Ok(());
72        };
73
74        match request {
75            NetDiagnosticsMessage::Auth(_) => {
76                connection.close(400u32.into(), b"Unexpected auth message");
77                anyhow::bail!("unexpected auth message");
78            }
79            NetDiagnosticsMessage::RunNetworkDiagnostics(msg) => {
80                let WithChannels { tx, .. } = msg;
81                let needed_caps = Caps::new([NetDiagnosticsCap::GetAny]);
82                if !capability.permits(&needed_caps) {
83                    return send_missing_caps(tx, needed_caps).await;
84                }
85
86                #[cfg(not(feature = "net_diagnostics"))]
87                {
88                    tx.send(Err(RemoteError::AuthError(
89                        "this endpoint does not support running remote diagnostics".to_string(),
90                    )))
91                    .await?;
92                }
93
94                #[cfg(feature = "net_diagnostics")]
95                {
96                    let report =
97                        crate::net_diagnostics::checks::run_diagnostics(&self.endpoint).await?;
98                    tx.send(Ok(report))
99                        .await
100                        .inspect_err(|e| warn!("sending network diagnostics response: {:?}", e))?;
101                }
102            }
103        }
104
105        connection.closed().await;
106        Ok(())
107    }
108}
109
110fn verify_rcan(endpoint: &Endpoint, remote_node: EndpointId, rcan: &Rcan<Caps>) -> Result<()> {
111    // Must be a first-party token (not delegated)
112    ensure!(
113        matches!(rcan.capability_origin(), CapabilityOrigin::Issuer),
114        "invalid capability origin: expected first-party token"
115    );
116
117    // Issuer must be this endpoint (we issued this grant)
118    ensure!(
119        EndpointId::try_from(rcan.issuer().as_bytes())
120            .map(|id| id == endpoint.id())
121            .unwrap_or(false),
122        "invalid issuer: RCAN was not issued by this endpoint"
123    );
124
125    // Audience must be the remote node (the token is for them)
126    ensure!(
127        EndpointId::try_from(rcan.audience().as_bytes())
128            .map(|id| id == remote_node)
129            .unwrap_or(false),
130        "invalid audience: RCAN audience does not match remote node"
131    );
132
133    Ok(())
134}
135
136async fn send_missing_caps<T>(
137    tx: irpc::channel::oneshot::Sender<Result<T, RemoteError>>,
138    missing_caps: Caps,
139) -> Result<()> {
140    tx.send(Err(RemoteError::MissingCapability(missing_caps)))
141        .await?;
142    Ok(())
143}
144
145#[cfg(test)]
146#[cfg(feature = "net_diagnostics")]
147mod tests {
148    use iroh::{RelayMode, address_lookup::MemoryLookup, protocol::Router};
149    use irpc_iroh::IrohLazyRemoteConnection;
150    use n0_future::time::Duration;
151
152    use super::*;
153    use crate::{
154        ALPN,
155        caps::create_grant_token,
156        protocol::{Auth, IrohServicesClient, RunNetworkDiagnostics},
157    };
158
159    #[tokio::test]
160    async fn test_diagnostics_host_run_diagnostics() {
161        let lookup = MemoryLookup::new();
162        let server_ep = iroh::Endpoint::empty_builder(RelayMode::Disabled)
163            .address_lookup(lookup.clone())
164            .bind()
165            .await
166            .unwrap();
167
168        let client_ep = iroh::Endpoint::empty_builder(RelayMode::Disabled)
169            .address_lookup(lookup.clone())
170            .bind()
171            .await
172            .unwrap();
173
174        let host = ClientHost::new(&server_ep);
175        let router = Router::builder(server_ep.clone())
176            .accept(CLIENT_HOST_ALPN, host)
177            .spawn();
178
179        // The server grants capabilities to the client.
180        let rcan = create_grant_token(
181            server_ep.secret_key().clone(),
182            client_ep.id(),
183            Duration::from_secs(3600),
184            Caps::for_shared_secret(),
185        )
186        .unwrap();
187
188        // Connect on the net diagnostics ALPN
189        let conn = IrohLazyRemoteConnection::new(
190            client_ep.clone(),
191            server_ep.addr(),
192            CLIENT_HOST_ALPN.to_vec(),
193        );
194        let client = ClientHostClient::boxed(conn);
195
196        // authenticate with the server-issued grant
197        client.rpc(Auth { caps: rcan }).await.unwrap();
198
199        // send RunNetworkDiagnostics and verify we get a report back
200        let result = client.rpc(RunNetworkDiagnostics).await.unwrap();
201        let report = result.expect("expected Ok(DiagnosticsReport)");
202        assert_eq!(report.endpoint_id, server_ep.id());
203
204        router.shutdown().await.unwrap();
205        client_ep.close().await;
206    }
207
208    #[tokio::test]
209    async fn test_client_host_rejects_self_signed_rcan() {
210        let lookup = MemoryLookup::new();
211        let server_ep = iroh::Endpoint::empty_builder(RelayMode::Disabled)
212            .address_lookup(lookup.clone())
213            .bind()
214            .await
215            .unwrap();
216
217        let client_ep = iroh::Endpoint::empty_builder(RelayMode::Disabled)
218            .address_lookup(lookup.clone())
219            .bind()
220            .await
221            .unwrap();
222
223        let host = ClientHost::new(&server_ep);
224        let router = Router::builder(server_ep.clone())
225            .accept(ALPN, host)
226            .spawn();
227
228        // Client creates its own RCAN (self-signed, not issued by server).
229        let rcan = create_grant_token(
230            client_ep.secret_key().clone(),
231            client_ep.id(),
232            Duration::from_secs(3600),
233            Caps::for_shared_secret(),
234        )
235        .unwrap();
236
237        let conn =
238            IrohLazyRemoteConnection::new(client_ep.clone(), server_ep.addr(), ALPN.to_vec());
239        let client = IrohServicesClient::boxed(conn);
240
241        // auth should fail because the RCAN issuer is the client, not the server
242        let result = client.rpc(Auth { caps: rcan }).await;
243        assert!(
244            result.is_err(),
245            "expected auth to be rejected for self-signed RCAN"
246        );
247
248        router.shutdown().await.unwrap();
249        client_ep.close().await;
250    }
251}