1pub 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 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>, }
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
192pub 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
204pub 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#[derive(Debug, Clone, PartialEq, Eq, strum::Display)]
398#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
399enum ApiErrorStatus {
400 InvalidInput,
401 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
453impl<T: Error> From<T> for ApiErrorStatus {
455 fn from(value: T) -> Self {
456 Self::UnknownFailure(value.to_string())
457 }
458}
459
460impl<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}