Skip to main content

fedimint_server/consensus/
iroh_api.rs

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
27/// How long an Iroh API connection may stay idle before the server closes it.
28const IROH_API_CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
29
30/// Application-level QUIC error code for expected idle Iroh API connection
31/// reaping.
32const IROH_API_CONNECTION_IDLE_TIMEOUT_ERROR_CODE: u32 = 0;
33
34/// Application-level QUIC close reason for idle Iroh API connection reaping.
35const 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            &parallel_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
399// --- iroh-next API endpoint functions ---
400
401pub(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}