Skip to main content

fedimint_server/consensus/
iroh_api.rs

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