hoprd_api/
lib.rs

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