iroh_services/
client_host.rs1use 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
18pub const CLIENT_HOST_ALPN: &[u8] = b"n0/n0des-client-host/1";
20
21pub type ClientHostClient = irpc::Client<ClientHostProtocol>;
22
23#[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 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 ensure!(
113 matches!(rcan.capability_origin(), CapabilityOrigin::Issuer),
114 "invalid capability origin: expected first-party token"
115 );
116
117 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 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 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 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 client.rpc(Auth { caps: rcan }).await.unwrap();
198
199 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 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 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}