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