hoprd_api/
lib.rs

1//! REST API for the HOPRd node.
2pub mod config;
3
4mod account;
5mod alias;
6mod channels;
7mod checks;
8mod messages;
9mod network;
10mod node;
11mod peers;
12mod preconditions;
13mod prometheus;
14mod session;
15mod tickets;
16mod types;
17
18pub(crate) mod env {
19    /// Name of the environment variable specifying automatic port range selection for Sessions.
20    /// Expected format: "<start_port>:<end_port>" (e.g., "9091:9099")
21    pub const HOPRD_SESSION_PORT_RANGE: &str = "HOPRD_SESSION_PORT_RANGE";
22}
23
24pub use session::{HOPR_TCP_BUFFER_SIZE, HOPR_UDP_BUFFER_SIZE, HOPR_UDP_QUEUE_SIZE};
25
26use async_lock::RwLock;
27use axum::{
28    extract::Json,
29    http::{header::AUTHORIZATION, status::StatusCode, Method},
30    middleware,
31    response::{IntoResponse, Response},
32    routing::{delete, get, post},
33    Router,
34};
35use serde::Serialize;
36use std::error::Error;
37use std::iter::once;
38use std::sync::Arc;
39use std::{collections::HashMap, sync::atomic::AtomicU16};
40use tokio::net::TcpListener;
41use tower::ServiceBuilder;
42use tower_http::{
43    compression::CompressionLayer,
44    cors::{Any, CorsLayer},
45    sensitive_headers::SetSensitiveRequestHeadersLayer,
46    trace::TraceLayer,
47    validate_request::ValidateRequestHeaderLayer,
48};
49use utoipa::openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme};
50use utoipa::{Modify, OpenApi};
51use utoipa_scalar::{Scalar, Servable as ScalarServable};
52use utoipa_swagger_ui::SwaggerUi;
53
54use hopr_lib::{errors::HoprLibError, Address, Hopr};
55use hopr_network_types::prelude::IpProtocol;
56
57use crate::config::Auth;
58use crate::session::StoredSessionEntry;
59
60pub(crate) const BASE_PATH: &str = "/api/v3";
61
62#[derive(Clone)]
63pub(crate) struct AppState {
64    pub hopr: Arc<Hopr>, // checks
65}
66
67pub type MessageEncoder = fn(&[u8]) -> Box<[u8]>;
68
69#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
70pub struct ListenerId(pub IpProtocol, pub std::net::SocketAddr);
71
72pub type ListenerJoinHandles = Arc<RwLock<HashMap<ListenerId, StoredSessionEntry>>>;
73
74#[derive(Clone)]
75pub(crate) struct InternalState {
76    pub hoprd_cfg: String,
77    pub auth: Arc<Auth>,
78    pub hopr: Arc<Hopr>,
79    pub inbox: Arc<RwLock<hoprd_inbox::Inbox>>,
80    pub hoprd_db: Arc<hoprd_db_api::db::HoprdDb>,
81    pub websocket_active_count: Arc<AtomicU16>,
82    pub open_listeners: ListenerJoinHandles,
83    pub default_listen_host: std::net::SocketAddr,
84}
85
86#[derive(OpenApi)]
87#[openapi(
88    paths(
89        account::addresses,
90        account::balances,
91        account::withdraw,
92        alias::aliases,
93        alias::aliases_addresses,
94        alias::set_alias,
95        alias::get_alias,
96        alias::get_alias_address,
97        alias::delete_alias,
98        alias::clear_aliases,
99        channels::close_channel,
100        channels::fund_channel,
101        channels::list_channels,
102        channels::open_channel,
103        channels::show_channel,
104        checks::eligiblez,
105        checks::healthyz,
106        checks::readyz,
107        checks::startedz,
108        messages::delete_messages,
109        messages::peek,
110        messages::peek_all,
111        messages::pop,
112        messages::pop_all,
113        messages::send_message,
114        messages::size,
115        network::price,
116        network::probability,
117        node::configuration,
118        node::entry_nodes,
119        node::info,
120        node::channel_graph,
121        node::peers,
122        node::version,
123        peers::ping_peer,
124        peers::show_peer_info,
125        session::create_client,
126        session::list_clients,
127        session::close_client,
128        tickets::aggregate_tickets_in_channel,
129        tickets::redeem_all_tickets,
130        tickets::redeem_tickets_in_channel,
131        tickets::show_all_tickets,
132        tickets::show_channel_tickets,
133        tickets::show_ticket_statistics,
134        tickets::reset_ticket_statistics,
135    ),
136    components(
137        schemas(
138            ApiError,
139            account::AccountAddressesResponse, account::AccountBalancesResponse, account::WithdrawBodyRequest, account::WithdrawResponse,
140            alias::PeerIdResponse, alias::AliasDestinationBodyRequest,
141            channels::ChannelsQueryRequest,channels::CloseChannelResponse, channels::OpenChannelBodyRequest, channels::OpenChannelResponse,
142            channels::NodeChannel, channels::NodeChannelsResponse, channels::ChannelInfoResponse, channels::FundBodyRequest,
143            messages::MessagePopAllResponse,
144            messages::MessagePopResponse, messages::SendMessageResponse, messages::SendMessageBodyRequest, messages::SizeResponse, messages::TagQueryRequest, messages::GetMessageBodyRequest,
145            network::TicketPriceResponse,
146            network::TicketProbabilityResponse,
147            node::EntryNode, node::NodeInfoResponse, node::NodePeersQueryRequest,
148            node::HeartbeatInfo, node::PeerInfo, node::AnnouncedPeer, node::NodePeersResponse, node::NodeVersionResponse, node::GraphExportQuery,
149            peers::NodePeerInfoResponse, peers::PingResponse,
150            session::SessionClientRequest, session::SessionCapability, session::RoutingOptions, session::SessionTargetSpec, session::SessionClientResponse, session::IpProtocol,
151            tickets::NodeTicketStatisticsResponse, tickets::ChannelTicket,
152        )
153    ),
154    modifiers(&SecurityAddon),
155    tags(
156        (name = "Account", description = "HOPR node account endpoints"),
157        (name = "Alias", description = "HOPR node internal non-persistent alias endpoints"),
158        (name = "Channels", description = "HOPR node chain channels manipulation endpoints"),
159        (name = "Checks", description = "HOPR node functionality checks"),
160        (name = "Messages", description = "HOPR node message manipulation endpoints"),
161        (name = "Node", description = "HOPR node information endpoints"),
162        (name = "Peers", description = "HOPR node peer manipulation endpoints"),
163        (name = "Tickets", description = "HOPR node ticket management endpoints"),
164    )
165)]
166pub struct ApiDoc;
167
168pub struct SecurityAddon;
169
170impl Modify for SecurityAddon {
171    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
172        let components = openapi
173            .components
174            .as_mut()
175            .expect("components should be registered at this point");
176        components.add_security_scheme(
177            "bearer_token",
178            SecurityScheme::Http(
179                HttpBuilder::new()
180                    .scheme(HttpAuthScheme::Bearer)
181                    .bearer_format("token")
182                    .build(),
183            ),
184        );
185        components.add_security_scheme(
186            "api_token",
187            SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("X-Auth-Token"))),
188        );
189    }
190}
191
192/// Parameters needed to construct the Rest API via [`serve_api`].
193pub struct RestApiParameters {
194    pub listener: TcpListener,
195    pub hoprd_cfg: String,
196    pub cfg: crate::config::Api,
197    pub hopr: Arc<hopr_lib::Hopr>,
198    pub hoprd_db: Arc<hoprd_db_api::db::HoprdDb>,
199    pub inbox: Arc<RwLock<hoprd_inbox::Inbox>>,
200    pub session_listener_sockets: ListenerJoinHandles,
201    pub default_session_listen_host: std::net::SocketAddr,
202}
203
204/// Starts the Rest API listener and router.
205pub async fn serve_api(params: RestApiParameters) -> Result<(), std::io::Error> {
206    let RestApiParameters {
207        listener,
208        hoprd_cfg,
209        cfg,
210        hopr,
211        hoprd_db,
212        inbox,
213        session_listener_sockets,
214        default_session_listen_host,
215    } = params;
216
217    let router = build_api(
218        hoprd_cfg,
219        cfg,
220        hopr,
221        inbox,
222        hoprd_db,
223        session_listener_sockets,
224        default_session_listen_host,
225    )
226    .await;
227    axum::serve(listener, router).await
228}
229
230#[allow(clippy::too_many_arguments)]
231async fn build_api(
232    hoprd_cfg: String,
233    cfg: crate::config::Api,
234    hopr: Arc<hopr_lib::Hopr>,
235    inbox: Arc<RwLock<hoprd_inbox::Inbox>>,
236    hoprd_db: Arc<hoprd_db_api::db::HoprdDb>,
237    open_listeners: ListenerJoinHandles,
238    default_listen_host: std::net::SocketAddr,
239) -> Router {
240    let state = AppState { hopr };
241    let inner_state = InternalState {
242        auth: Arc::new(cfg.auth.clone()),
243        hoprd_cfg,
244        hopr: state.hopr.clone(),
245        inbox,
246        hoprd_db,
247        open_listeners,
248        default_listen_host,
249        websocket_active_count: Arc::new(AtomicU16::new(0)),
250    };
251
252    Router::new()
253        .merge(
254            Router::new()
255                .merge(SwaggerUi::new("/swagger-ui").url("/api-docs2/openapi.json", ApiDoc::openapi()))
256                .merge(Scalar::with_url("/scalar", ApiDoc::openapi())),
257        )
258        .merge(
259            Router::new()
260                .route("/startedz", get(checks::startedz))
261                .route("/readyz", get(checks::readyz))
262                .route("/healthyz", get(checks::healthyz))
263                .route("/eligiblez", get(checks::eligiblez))
264                .with_state(state.into()),
265        )
266        .merge(
267            Router::new()
268                .route("/metrics", get(node::metrics))
269                .layer(middleware::from_fn_with_state(
270                    inner_state.clone(),
271                    preconditions::authenticate,
272                ))
273                .layer(middleware::from_fn_with_state(
274                    inner_state.clone(),
275                    preconditions::cap_websockets,
276                ))
277                .layer(
278                    ServiceBuilder::new()
279                        .layer(TraceLayer::new_for_http())
280                        .layer(
281                            CorsLayer::new()
282                                .allow_methods([Method::GET])
283                                .allow_origin(Any)
284                                .allow_headers(Any)
285                                .max_age(std::time::Duration::from_secs(86400)),
286                        )
287                        .layer(middleware::from_fn(prometheus::record))
288                        .layer(CompressionLayer::new())
289                        .layer(ValidateRequestHeaderLayer::accept("text/plain"))
290                        .layer(SetSensitiveRequestHeadersLayer::new(once(AUTHORIZATION))),
291                ),
292        )
293        .nest(
294            BASE_PATH,
295            Router::new()
296                .route("/aliases", get(alias::aliases))
297                .route("/aliases_addresses", get(alias::aliases_addresses))
298                .route("/aliases", post(alias::set_alias))
299                .route("/aliases", delete(alias::clear_aliases))
300                .route("/aliases/{alias}", get(alias::get_alias))
301                .route("/aliases_addresses/{alias}", get(alias::get_alias_address))
302                .route("/aliases/{alias}", delete(alias::delete_alias))
303                .route("/account/addresses", get(account::addresses))
304                .route("/account/balances", get(account::balances))
305                .route("/account/withdraw", post(account::withdraw))
306                .route("/peers/{destination}", get(peers::show_peer_info))
307                .route("/channels", get(channels::list_channels))
308                .route("/channels", post(channels::open_channel))
309                .route("/channels/{channelId}", get(channels::show_channel))
310                .route("/channels/{channelId}/tickets", get(tickets::show_channel_tickets))
311                .route("/channels/{channelId}", delete(channels::close_channel))
312                .route("/channels/{channelId}/fund", post(channels::fund_channel))
313                .route(
314                    "/channels/{channelId}/tickets/redeem",
315                    post(tickets::redeem_tickets_in_channel),
316                )
317                .route(
318                    "/channels/{channelId}/tickets/aggregate",
319                    post(tickets::aggregate_tickets_in_channel),
320                )
321                .route("/tickets", get(tickets::show_all_tickets))
322                .route("/tickets/redeem", post(tickets::redeem_all_tickets))
323                .route("/tickets/statistics", get(tickets::show_ticket_statistics))
324                .route("/tickets/statistics", delete(tickets::reset_ticket_statistics))
325                .route("/messages", delete(messages::delete_messages))
326                .route("/messages", post(messages::send_message))
327                .route("/messages/pop", post(messages::pop))
328                .route("/messages/pop-all", post(messages::pop_all))
329                .route("/messages/peek", post(messages::peek))
330                .route("/messages/peek-all", post(messages::peek_all))
331                .route("/messages/size", get(messages::size))
332                .route("/network/price", get(network::price))
333                .route("/network/probability", get(network::probability))
334                .route("/node/version", get(node::version))
335                .route("/node/configuration", get(node::configuration))
336                .route("/node/info", get(node::info))
337                .route("/node/peers", get(node::peers))
338                .route("/node/entryNodes", get(node::entry_nodes))
339                .route("/node/graph", get(node::channel_graph))
340                .route("/peers/{destination}/ping", post(peers::ping_peer))
341                .route("/session/websocket", get(session::websocket))
342                .route("/session/{protocol}", post(session::create_client))
343                .route("/session/{protocol}", get(session::list_clients))
344                .route("/session/{protocol}/{ip}/{port}", delete(session::close_client))
345                .with_state(inner_state.clone().into())
346                .layer(middleware::from_fn_with_state(
347                    inner_state.clone(),
348                    preconditions::authenticate,
349                ))
350                .layer(middleware::from_fn_with_state(
351                    inner_state.clone(),
352                    preconditions::cap_websockets,
353                ))
354                .layer(
355                    ServiceBuilder::new()
356                        .layer(TraceLayer::new_for_http())
357                        .layer(
358                            CorsLayer::new()
359                                .allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::DELETE])
360                                .allow_origin(Any)
361                                .allow_headers(Any)
362                                .max_age(std::time::Duration::from_secs(86400)),
363                        )
364                        .layer(middleware::from_fn(prometheus::record))
365                        .layer(CompressionLayer::new())
366                        .layer(ValidateRequestHeaderLayer::accept("application/json"))
367                        .layer(SetSensitiveRequestHeadersLayer::new(once(AUTHORIZATION))),
368                ),
369        )
370}
371
372fn checksum_address_serializer<S: serde::Serializer>(a: &Address, s: S) -> Result<S::Ok, S::Error> {
373    s.serialize_str(&a.to_checksum())
374}
375
376fn option_checksum_address_serializer<S: serde::Serializer>(a: &Option<Address>, s: S) -> Result<S::Ok, S::Error> {
377    if let Some(addr) = a {
378        s.serialize_some(&addr.to_checksum())
379    } else {
380        s.serialize_none()
381    }
382}
383
384#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
385#[schema(example = json!({
386    "status": "INVALID_INPUT",
387    "error": "Invalid value passed in parameter 'XYZ'"
388}))]
389pub(crate) struct ApiError {
390    pub status: String,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub error: Option<String>,
393}
394
395/// Enumerates all API request errors
396/// Note that `ApiError` should not be instantiated directly, but always rather through the `ApiErrorStatus`.
397#[derive(Debug, Clone, PartialEq, Eq, strum::Display)]
398#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
399enum ApiErrorStatus {
400    InvalidInput,
401    /// An invalid application tag from the reserved range was provided.
402    InvalidApplicationTag,
403    InvalidChannelId,
404    PeerNotFound,
405    ChannelNotFound,
406    TicketsNotFound,
407    NotEnoughBalance,
408    NotEnoughAllowance,
409    ChannelAlreadyOpen,
410    ChannelNotOpen,
411    AliasNotFound,
412    AliasOrPeerIdAliasAlreadyExists,
413    DatabaseError,
414    UnsupportedFeature,
415    Timeout,
416    PingError(String),
417    Unauthorized,
418    TooManyOpenWebsocketConnections,
419    InvalidQuality,
420    NotReady,
421    ListenHostAlreadyUsed,
422    #[strum(serialize = "INVALID_PATH")]
423    InvalidPath(String),
424    #[strum(serialize = "UNKNOWN_FAILURE")]
425    UnknownFailure(String),
426}
427
428impl From<ApiErrorStatus> for ApiError {
429    fn from(value: ApiErrorStatus) -> Self {
430        Self {
431            status: value.to_string(),
432            error: if let ApiErrorStatus::UnknownFailure(e) = value {
433                Some(e)
434            } else {
435                None
436            },
437        }
438    }
439}
440
441impl IntoResponse for ApiErrorStatus {
442    fn into_response(self) -> Response {
443        Json(ApiError::from(self)).into_response()
444    }
445}
446
447impl IntoResponse for ApiError {
448    fn into_response(self) -> Response {
449        (StatusCode::INTERNAL_SERVER_ERROR, Json(self)).into_response()
450    }
451}
452
453// Errors lead to `UnknownFailure` per default
454impl<T: Error> From<T> for ApiErrorStatus {
455    fn from(value: T) -> Self {
456        Self::UnknownFailure(value.to_string())
457    }
458}
459
460// Errors lead to `UnknownFailure` per default
461impl<T> From<T> for ApiError
462where
463    T: Error + Into<HoprLibError>,
464{
465    fn from(value: T) -> Self {
466        Self {
467            status: ApiErrorStatus::UnknownFailure("unknown error".to_string()).to_string(),
468            error: Some(value.to_string()),
469        }
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::ApiError;
476
477    use axum::http::StatusCode;
478    use axum::response::IntoResponse;
479
480    #[test]
481    fn test_api_error_to_response() {
482        let error = ApiError {
483            status: StatusCode::INTERNAL_SERVER_ERROR.to_string(),
484            error: Some("Invalid value passed in parameter 'XYZ'".to_string()),
485        };
486
487        let response = error.into_response();
488        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
489    }
490}