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 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
182pub 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
192pub 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}))]
367pub(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#[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
429impl<T: Error> From<T> for ApiErrorStatus {
431 fn from(value: T) -> Self {
432 Self::UnknownFailure(value.to_string())
433 }
434}
435
436impl<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}