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