1pub 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 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>, }
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
179pub 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
189pub 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}))]
361pub(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#[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
421impl<T: Error> From<T> for ApiErrorStatus {
423 fn from(value: T) -> Self {
424 Self::UnknownFailure(value.to_string())
425 }
426}
427
428impl<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}