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