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