1use std::collections::BTreeMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use fedimint_core::core::ModuleInstanceId;
6use fedimint_core::module::{ApiEndpoint, ApiError, ApiMethod, IrohApiRequest};
7use fedimint_core::task::TaskGroup;
8use fedimint_core::util::FmtCompactAnyhow as _;
9use fedimint_logging::LOG_NET_API;
10use fedimint_metrics::prometheus::HistogramTimer;
11use fedimint_server_core::DynServerModule;
12use futures::FutureExt as _;
13use iroh::Endpoint;
14use iroh::endpoint::{Incoming, RecvStream, SendStream, VarInt};
15use serde_json::Value;
16use tokio::sync::Semaphore;
17use tracing::warn;
18
19use super::api::{ConsensusApi, server_endpoints};
20use crate::connection_limits::ConnectionLimits;
21use crate::metrics::{
22 IROH_API_CONNECTION_DURATION_SECONDS, IROH_API_CONNECTION_IDLE_TIMEOUT_TOTAL,
23 IROH_API_CONNECTIONS_ACTIVE, IROH_API_REQUEST_DURATION_SECONDS, IROH_API_REQUEST_RESPONSE_CODE,
24};
25use crate::net::api::HasApiContext;
26
27const IROH_API_CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
29
30const IROH_API_CONNECTION_IDLE_TIMEOUT_ERROR_CODE: u32 = 0;
33
34const IROH_API_CONNECTION_IDLE_TIMEOUT_ERROR_REASON: &[u8] = b"idle timeout";
36
37pub(super) async fn run_iroh_api(
38 api: Arc<IrohApiState>,
39 endpoint: Endpoint,
40 task_group: TaskGroup,
41) {
42 loop {
43 match endpoint.accept().await {
44 Some(incoming) => {
45 let permit = acquire_iroh_api_permit(
46 &api.parallel_connections_limit,
47 api.limits.max_connections,
48 "0.35",
49 "connection",
50 )
51 .await;
52 task_group.spawn_cancellable_silent(
53 "handle-iroh-connection",
54 handle_incoming(
55 api.clone(),
56 task_group.clone(),
57 incoming,
58 permit,
59 api.limits.max_requests_per_connection,
60 )
61 .then(|result| async {
62 if let Err(err) = result {
63 warn!(target: LOG_NET_API, err = %err.fmt_compact_anyhow(), "Failed to handle iroh connection");
64 }
65 }),
66 );
67 }
68 None => return,
69 }
70 }
71}
72
73type CoreApi = BTreeMap<String, ApiEndpoint<ConsensusApi>>;
74type ModuleApi = BTreeMap<ModuleInstanceId, BTreeMap<String, ApiEndpoint<DynServerModule>>>;
75
76pub(super) struct IrohApiState {
77 consensus: ConsensusApi,
78 core: CoreApi,
79 modules: ModuleApi,
80 limits: ConnectionLimits,
81 parallel_connections_limit: Arc<Semaphore>,
82}
83
84impl IrohApiState {
85 pub(super) fn new(consensus: ConsensusApi, limits: ConnectionLimits) -> Arc<Self> {
86 let core_api = server_endpoints()
87 .into_iter()
88 .map(|endpoint| (endpoint.path.to_string(), endpoint))
89 .collect();
90
91 let module_api = consensus
92 .modules
93 .iter_modules()
94 .map(|(id, _, module)| {
95 let api_endpoints = module
96 .api_endpoints()
97 .into_iter()
98 .map(|endpoint| (endpoint.path.to_string(), endpoint))
99 .collect::<BTreeMap<String, ApiEndpoint<DynServerModule>>>();
100
101 (id, api_endpoints)
102 })
103 .collect();
104
105 Arc::new(Self {
106 consensus,
107 core: core_api,
108 modules: module_api,
109 parallel_connections_limit: Arc::new(Semaphore::new(limits.max_connections)),
110 limits,
111 })
112 }
113}
114
115async fn acquire_iroh_api_permit(
116 limit: &Arc<Semaphore>,
117 max: usize,
118 version: &'static str,
119 resource: &'static str,
120) -> tokio::sync::OwnedSemaphorePermit {
121 if limit.available_permits() == 0 {
122 warn!(
123 target: LOG_NET_API,
124 limit = max,
125 version,
126 resource,
127 "Iroh API limit reached, blocking"
128 );
129 }
130 limit
131 .clone()
132 .acquire_owned()
133 .await
134 .expect("semaphore should not be closed")
135}
136
137struct ActiveIrohApiConnection {
138 _duration: HistogramTimer,
139}
140
141impl ActiveIrohApiConnection {
142 fn new() -> Self {
143 IROH_API_CONNECTIONS_ACTIVE.inc();
144 Self {
145 _duration: IROH_API_CONNECTION_DURATION_SECONDS.start_timer(),
146 }
147 }
148}
149
150impl Drop for ActiveIrohApiConnection {
151 fn drop(&mut self) {
152 IROH_API_CONNECTIONS_ACTIVE.dec();
153 }
154}
155
156async fn handle_incoming(
157 api: Arc<IrohApiState>,
158 task_group: TaskGroup,
159 incoming: Incoming,
160 connection_permit: tokio::sync::OwnedSemaphorePermit,
161 iroh_api_max_requests_per_connection: usize,
162) -> anyhow::Result<()> {
163 let connection = incoming.accept()?.await?;
164 handle_iroh_api_connection(
165 api,
166 task_group,
167 VersionedIrohConnection::Legacy(connection),
168 connection_permit,
169 iroh_api_max_requests_per_connection,
170 IrohApiVersion::Legacy,
171 )
172 .await
173}
174
175#[derive(Clone, Copy)]
176enum IrohApiVersion {
177 Legacy,
178 Next,
179}
180
181impl IrohApiVersion {
182 fn log_label(self) -> &'static str {
183 match self {
184 Self::Legacy => "0.35",
185 Self::Next => "1.0",
186 }
187 }
188
189 fn metric_label(self) -> &'static str {
190 match self {
191 Self::Legacy => "default",
192 Self::Next => "next",
193 }
194 }
195
196 fn request_task_name(self) -> &'static str {
197 match self {
198 Self::Legacy => "handle-iroh-request",
199 Self::Next => "handle-iroh-next-request",
200 }
201 }
202}
203
204enum VersionedIrohConnection {
205 Legacy(iroh::endpoint::Connection),
206 Next(iroh_next::endpoint::Connection),
207}
208
209impl VersionedIrohConnection {
210 async fn accept_bi(&self) -> anyhow::Result<(VersionedSendStream, VersionedRecvStream)> {
211 Ok(match self {
212 Self::Legacy(connection) => {
213 let (send, recv) = connection.accept_bi().await?;
214 (
215 VersionedSendStream::Legacy(send),
216 VersionedRecvStream::Legacy(recv),
217 )
218 }
219 Self::Next(connection) => {
220 let (send, recv) = connection.accept_bi().await?;
221 (
222 VersionedSendStream::Next(send),
223 VersionedRecvStream::Next(recv),
224 )
225 }
226 })
227 }
228
229 fn close_for_idle_timeout(&self) {
230 match self {
231 Self::Legacy(connection) => connection.close(
232 VarInt::from_u32(IROH_API_CONNECTION_IDLE_TIMEOUT_ERROR_CODE),
233 IROH_API_CONNECTION_IDLE_TIMEOUT_ERROR_REASON,
234 ),
235 Self::Next(connection) => connection.close(
236 iroh_next::endpoint::VarInt::from_u32(IROH_API_CONNECTION_IDLE_TIMEOUT_ERROR_CODE),
237 IROH_API_CONNECTION_IDLE_TIMEOUT_ERROR_REASON,
238 ),
239 }
240 }
241}
242
243enum VersionedSendStream {
244 Legacy(SendStream),
245 Next(iroh_next::endpoint::SendStream),
246}
247
248impl VersionedSendStream {
249 async fn write_response(mut self, response: &[u8]) -> anyhow::Result<()> {
250 match &mut self {
251 Self::Legacy(send) => {
252 send.write_all(response).await?;
253 send.finish()?;
254 }
255 Self::Next(send) => {
256 send.write_all(response).await?;
257 send.finish()?;
258 }
259 }
260 Ok(())
261 }
262}
263
264enum VersionedRecvStream {
265 Legacy(RecvStream),
266 Next(iroh_next::endpoint::RecvStream),
267}
268
269impl VersionedRecvStream {
270 async fn read_request(&mut self) -> anyhow::Result<Vec<u8>> {
271 Ok(match self {
272 Self::Legacy(recv) => recv.read_to_end(100_000).await?,
273 Self::Next(recv) => recv.read_to_end(100_000).await?,
274 })
275 }
276}
277
278async fn handle_iroh_api_connection(
279 api: Arc<IrohApiState>,
280 task_group: TaskGroup,
281 connection: VersionedIrohConnection,
282 _connection_permit: tokio::sync::OwnedSemaphorePermit,
283 max_requests: usize,
284 version: IrohApiVersion,
285) -> anyhow::Result<()> {
286 let parallel_requests_limit = Arc::new(Semaphore::new(max_requests));
287 let _metrics = ActiveIrohApiConnection::new();
288
289 loop {
290 let accept_result = fedimint_core::runtime::timeout(
291 IROH_API_CONNECTION_IDLE_TIMEOUT,
292 connection.accept_bi(),
293 )
294 .await;
295
296 let (send_stream, recv_stream) = match accept_result {
297 Ok(streams) => streams?,
298 Err(_) if parallel_requests_limit.available_permits() < max_requests => continue,
299 Err(_) => {
300 IROH_API_CONNECTION_IDLE_TIMEOUT_TOTAL.inc();
301 tracing::debug!(
302 target: LOG_NET_API,
303 version = version.log_label(),
304 idle_timeout_secs = IROH_API_CONNECTION_IDLE_TIMEOUT.as_secs(),
305 "Closing idle Iroh API connection"
306 );
307 connection.close_for_idle_timeout();
308 return Ok(());
309 }
310 };
311
312 let permit = acquire_iroh_api_permit(
313 ¶llel_requests_limit,
314 max_requests,
315 version.log_label(),
316 "request",
317 )
318 .await;
319 task_group.spawn_cancellable_silent(
320 version.request_task_name(),
321 handle_iroh_api_stream(
322 api.clone(),
323 send_stream,
324 recv_stream,
325 permit,
326 version.metric_label(),
327 )
328 .then(|result| async {
329 if let Err(err) = result {
330 warn!(target: LOG_NET_API, err = %err.fmt_compact_anyhow(), "Failed to handle Iroh API request");
331 }
332 }),
333 );
334 }
335}
336
337async fn handle_iroh_api_stream(
338 api: Arc<IrohApiState>,
339 send_stream: VersionedSendStream,
340 mut recv_stream: VersionedRecvStream,
341 _request_permit: tokio::sync::OwnedSemaphorePermit,
342 metric_label: &'static str,
343) -> anyhow::Result<()> {
344 let request = recv_stream.read_request().await?;
345 let response = handle_iroh_api_request(&api, &request, metric_label).await?;
346 send_stream.write_response(&response).await
347}
348
349async fn handle_iroh_api_request(
350 api: &IrohApiState,
351 request: &[u8],
352 version_label: &'static str,
353) -> anyhow::Result<Vec<u8>> {
354 let request = serde_json::from_slice::<IrohApiRequest>(request)?;
355 let method = request.method.to_string();
356 let timer = IROH_API_REQUEST_DURATION_SECONDS
357 .with_label_values(&[&method])
358 .start_timer();
359 let response = await_response(api, request).await;
360 timer.observe_duration();
361
362 let response_code = response
363 .as_ref()
364 .map_or_else(|err| err.code.to_string(), |_| "0".to_string());
365 IROH_API_REQUEST_RESPONSE_CODE
366 .with_label_values(&[method.as_str(), response_code.as_str(), version_label])
367 .inc();
368
369 Ok(serde_json::to_vec(&response)?)
370}
371
372async fn await_response(api: &IrohApiState, request: IrohApiRequest) -> Result<Value, ApiError> {
373 match request.method {
374 ApiMethod::Core(method) => {
375 let endpoint = api.core.get(&method).ok_or(ApiError::not_found(method))?;
376
377 let (state, context) = api.consensus.context(&request.request, None).await;
378
379 (endpoint.handler)(state, context, request.request).await
380 }
381 ApiMethod::Module(module_id, method) => {
382 let endpoint = api
383 .modules
384 .get(&module_id)
385 .ok_or(ApiError::not_found(module_id.to_string()))?
386 .get(&method)
387 .ok_or(ApiError::not_found(method))?;
388
389 let (state, context) = api
390 .consensus
391 .context(&request.request, Some(module_id))
392 .await;
393
394 (endpoint.handler)(state, context, request.request).await
395 }
396 }
397}
398
399pub(super) async fn run_iroh_api_next(
402 api: Arc<IrohApiState>,
403 endpoint: iroh_next::Endpoint,
404 task_group: TaskGroup,
405) {
406 loop {
407 match endpoint.accept().await {
408 Some(incoming) => {
409 let permit = acquire_iroh_api_permit(
410 &api.parallel_connections_limit,
411 api.limits.max_connections,
412 "1.0",
413 "connection",
414 )
415 .await;
416 task_group.spawn_cancellable_silent(
417 "handle-iroh-next-connection",
418 handle_incoming_next(
419 api.clone(),
420 task_group.clone(),
421 incoming,
422 permit,
423 api.limits.max_requests_per_connection,
424 )
425 .then(|result| async {
426 if let Err(err) = result {
427 warn!(target: LOG_NET_API, err = %err.fmt_compact_anyhow(), "Failed to handle iroh-next connection");
428 }
429 }),
430 );
431 }
432 None => return,
433 }
434 }
435}
436
437async fn handle_incoming_next(
438 api: Arc<IrohApiState>,
439 task_group: TaskGroup,
440 incoming: iroh_next::endpoint::Incoming,
441 connection_permit: tokio::sync::OwnedSemaphorePermit,
442 iroh_api_max_requests_per_connection: usize,
443) -> anyhow::Result<()> {
444 let connection = incoming.accept()?.await?;
445 handle_iroh_api_connection(
446 api,
447 task_group,
448 VersionedIrohConnection::Next(connection),
449 connection_permit,
450 iroh_api_max_requests_per_connection,
451 IrohApiVersion::Next,
452 )
453 .await
454}
455
456#[cfg(test)]
457mod tests {
458 use std::net::SocketAddr;
459
460 use anyhow::Context as _;
461 use iroh_next::endpoint::presets::Minimal;
462 use iroh_next::{EndpointAddr, RelayMode, SecretKey, TransportAddr};
463
464 use super::*;
465
466 const TEST_ALPN: &[u8] = b"fedimint-iroh-api-adapter-test";
467
468 #[tokio::test]
469 async fn shared_connection_limit_applies_across_versions() {
470 let limit = Arc::new(Semaphore::new(1));
471 let legacy_permit = acquire_iroh_api_permit(&limit, 1, "0.35", "connection").await;
472
473 assert!(
474 tokio::time::timeout(
475 Duration::from_millis(20),
476 acquire_iroh_api_permit(&limit, 1, "1.0", "connection"),
477 )
478 .await
479 .is_err()
480 );
481
482 drop(legacy_permit);
483 let _permit = tokio::time::timeout(
484 Duration::from_secs(1),
485 acquire_iroh_api_permit(&limit, 1, "1.0", "connection"),
486 )
487 .await
488 .expect("v1 acquires the shared permit after the legacy connection releases it");
489 }
490
491 #[tokio::test]
492 async fn iroh_v1_request_uses_shared_stream_adapter() -> anyhow::Result<()> {
493 let server = iroh_next::Endpoint::builder(Minimal)
494 .relay_mode(RelayMode::Disabled)
495 .secret_key(SecretKey::from_bytes(&[11; 32]))
496 .alpns(vec![TEST_ALPN.to_vec()])
497 .bind_addr(SocketAddr::from(([127, 0, 0, 1], 0)))?
498 .bind()
499 .await?;
500 let client = iroh_next::Endpoint::builder(Minimal)
501 .relay_mode(RelayMode::Disabled)
502 .bind()
503 .await?;
504 let server_addr = EndpointAddr::from_parts(
505 server.id(),
506 server.bound_sockets().into_iter().map(TransportAddr::Ip),
507 );
508 let (client_done_tx, client_done_rx) = tokio::sync::oneshot::channel();
509
510 let server_request = async {
511 let incoming = server.accept().await.context("server endpoint closed")?;
512 let connection = incoming.accept()?.await?;
513 let (send, mut recv) = VersionedIrohConnection::Next(connection)
514 .accept_bi()
515 .await?;
516 assert_eq!(recv.read_request().await?, b"request");
517 send.write_response(b"response").await?;
518 client_done_rx.await?;
519 anyhow::Ok(())
520 };
521 let client_request = async {
522 let connection = client.connect(server_addr, TEST_ALPN).await?;
523 let (mut send, mut recv) = connection.open_bi().await?;
524 send.write_all(b"request").await?;
525 send.finish()?;
526 let response = recv.read_to_end(100_000).await?;
527 anyhow::ensure!(response == b"response");
528 client_done_tx.send(()).expect("server is still running");
529 anyhow::Ok(())
530 };
531
532 tokio::time::timeout(Duration::from_secs(10), async {
533 tokio::try_join!(server_request, client_request)
534 })
535 .await
536 .context("Iroh v1 adapter test timed out")??;
537 client.close().await;
538 server.close().await;
539 Ok(())
540 }
541}