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;
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            let (state, context) = api.consensus.context(&request.request, None).await;
430
431            run_handler(
432                None,
433                &method,
434                (endpoint.handler)(state, context, request.request),
435            )
436            .await
437        }
438        ApiMethod::Module(module_id, method) => {
439            let endpoint = api
440                .modules
441                .get(&module_id)
442                .ok_or_else(|| ApiError::not_found(module_id.to_string()))?
443                .get(&method)
444                .ok_or_else(|| ApiError::not_found(method.clone()))?;
445
446            let (state, context) = api
447                .consensus
448                .context(&request.request, Some(module_id))
449                .await;
450
451            run_handler(
452                Some(module_id),
453                &method,
454                (endpoint.handler)(state, context, request.request),
455            )
456            .await
457        }
458    }
459}
460
461/// Runs an API endpoint handler, turning a panic into an error response for the
462/// caller that triggered it.
463///
464/// Iroh API requests run on the root task group, so an escaping panic would
465/// trip the task group's panic guard and shut the whole guardian down. The
466/// jsonrpsee path contains handler panics the same way.
467async fn run_handler(
468    module_id: Option<ModuleInstanceId>,
469    method: &str,
470    handler: impl Future<Output = Result<Value, ApiError>>,
471) -> Result<Value, ApiError> {
472    // Using `AssertUnwindSafe` here is far from ideal. In theory this means we
473    // could end up with an inconsistent state. In practice most API functions are
474    // only reading and the few that do write anything are atomic. Lastly, this is
475    // only the last line of defense.
476    AssertUnwindSafe(handler)
477        .catch_unwind()
478        .await
479        .unwrap_or_else(|_| {
480            error!(
481                target: LOG_NET_API,
482                module_id = ?module_id,
483                method,
484                "API handler panicked, DO NOT IGNORE, FIX IT!!!"
485            );
486
487            Err(ApiError::server_error("API handler panicked".to_string()))
488        })
489}
490
491// --- iroh-next API endpoint functions ---
492
493pub(super) async fn run_iroh_api_next(
494    api: Arc<IrohApiState>,
495    endpoint: iroh_next::Endpoint,
496    task_group: TaskGroup,
497) {
498    loop {
499        match endpoint.accept().await {
500            Some(incoming) => {
501                let permit = acquire_iroh_api_permit(
502                    &api.parallel_connections_limit,
503                    api.limits.max_connections,
504                    "1.0",
505                    "connection",
506                )
507                .await;
508                task_group.spawn_cancellable_silent(
509                    "handle-iroh-next-connection",
510                    handle_incoming_next(
511                        api.clone(),
512                        task_group.clone(),
513                        incoming,
514                        permit,
515                        api.limits.max_requests_per_connection,
516                    )
517                    .then(|result| async {
518                        if let Err(err) = result {
519                            warn!(target: LOG_NET_API, err = %err.fmt_compact(), "Failed to handle iroh-next connection");
520                        }
521                    }),
522                );
523            }
524            None => return,
525        }
526    }
527}
528
529async fn handle_incoming_next(
530    api: Arc<IrohApiState>,
531    task_group: TaskGroup,
532    incoming: iroh_next::endpoint::Incoming,
533    connection_permit: tokio::sync::OwnedSemaphorePermit,
534    iroh_api_max_requests_per_connection: usize,
535) -> anyhow::Result<()> {
536    let connection = incoming.accept()?.await?;
537    handle_iroh_api_connection(
538        api,
539        task_group,
540        VersionedIrohConnection::Next(connection),
541        connection_permit,
542        iroh_api_max_requests_per_connection,
543        IrohApiVersion::Next,
544    )
545    .await
546}
547
548#[cfg(test)]
549mod tests;