//! `frontend` contains HTTP and websocket endpoints for use by a website or web3 wallet. //! //! Important reading about axum extractors: // TODO: these are only public so docs are generated. What's a better way to do this? pub mod admin; pub mod authorization; pub mod errors; pub mod rpc_proxy_http; pub mod rpc_proxy_ws; pub mod status; pub mod users; use crate::errors::Web3ProxyResult; use crate::{app::Web3ProxyApp, errors::Web3ProxyError}; use axum::{ error_handling::HandleErrorLayer, routing::{get, post}, BoxError, Extension, Router, }; use http::{header::AUTHORIZATION, Request, StatusCode}; use hyper::Body; use listenfd::ListenFd; use moka::future::{Cache, CacheBuilder}; use std::sync::Arc; use std::{iter::once, time::Duration}; use std::{net::SocketAddr, sync::atomic::Ordering}; use strum::{EnumCount, EnumIter}; use tokio::sync::broadcast; use tower::timeout::TimeoutLayer; use tower::ServiceBuilder; use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer; use tower_http::{cors::CorsLayer, normalize_path::NormalizePathLayer, trace::TraceLayer}; use tracing::{error_span, info}; use ulid::Ulid; /// simple keys for caching responses #[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, EnumCount, EnumIter)] pub enum ResponseCacheKey { BackupsNeeded, Health, Status, } pub type ResponseCache = Cache; /// Start the frontend server. pub async fn serve( app: Arc, mut shutdown_receiver: broadcast::Receiver<()>, shutdown_complete_sender: broadcast::Sender<()>, ) -> Web3ProxyResult<()> { // setup caches for whatever the frontend needs // no need for max items since it is limited by the enum key // TODO: latest moka allows for different ttls for different let response_cache_size = ResponseCacheKey::COUNT; let response_cache: ResponseCache = CacheBuilder::new(response_cache_size as u64) .name("frontend_response") .time_to_live(Duration::from_millis(100)) .build(); // TODO: read config for if fastest/versus should be available publicly. default off // build our axum Router let router = Router::new() // TODO: i think these routes could be done a lot better // // HTTP RPC (POST) // // Websocket RPC (GET) // If not an RPC, GET will redirect to urls in the config // // public .route( "/", post(rpc_proxy_http::proxy_web3_rpc).get(rpc_proxy_ws::websocket_handler), ) // authenticated with and without trailing slash .route( "/rpc/:rpc_key", post(rpc_proxy_http::proxy_web3_rpc_with_key) .get(rpc_proxy_ws::websocket_handler_with_key), ) .route( "/rpc/:rpc_key/", post(rpc_proxy_http::proxy_web3_rpc_with_key) .get(rpc_proxy_ws::websocket_handler_with_key), ) // authenticated debug route .route( "/debug/:rpc_key", post(rpc_proxy_http::debug_proxy_web3_rpc_with_key) .get(rpc_proxy_ws::debug_websocket_handler_with_key), ) .route( "/debug/:rpc_key/", post(rpc_proxy_http::debug_proxy_web3_rpc_with_key) .get(rpc_proxy_ws::debug_websocket_handler_with_key), ) // public fastest .route( "/fastest", post(rpc_proxy_http::fastest_proxy_web3_rpc) .get(rpc_proxy_ws::fastest_websocket_handler), ) .route( "/fastest/", post(rpc_proxy_http::fastest_proxy_web3_rpc) .get(rpc_proxy_ws::fastest_websocket_handler), ) // authenticated fastest with and without trailing slash .route( "/fastest/:rpc_key", post(rpc_proxy_http::fastest_proxy_web3_rpc_with_key) .get(rpc_proxy_ws::fastest_websocket_handler_with_key), ) .route( "/fastest/:rpc_key/", post(rpc_proxy_http::fastest_proxy_web3_rpc_with_key) .get(rpc_proxy_ws::fastest_websocket_handler_with_key), ) // public versus .route( "/versus", post(rpc_proxy_http::versus_proxy_web3_rpc).get(rpc_proxy_ws::versus_websocket_handler), ) .route( "/versus/", post(rpc_proxy_http::versus_proxy_web3_rpc).get(rpc_proxy_ws::versus_websocket_handler), ) // authenticated versus .route( "/versus/:rpc_key", post(rpc_proxy_http::versus_proxy_web3_rpc_with_key) .get(rpc_proxy_ws::versus_websocket_handler_with_key), ) .route( "/versus/:rpc_key/", post(rpc_proxy_http::versus_proxy_web3_rpc_with_key) .get(rpc_proxy_ws::versus_websocket_handler_with_key), ) // // System things // .route("/health", get(status::health)) .route("/status", get(status::status)) .route("/status/backups_needed", get(status::backups_needed)) .route("/status/debug_request", get(status::debug_request)) // // User stuff // .route( "/user/login/:user_address", get(users::authentication::user_login_get), ) .route( "/user/login/:user_address/:message_eip", get(users::authentication::user_login_get), ) .route("/user/login", post(users::authentication::user_login_post)) .route( // /:rpc_key/:subuser_address/:new_status/:new_role "/user/subuser", post(users::subuser::modify_subuser), ) .route("/user/subusers", get(users::subuser::get_subusers)) .route( "/subuser/rpc_keys", get(users::subuser::get_keys_as_subuser), ) .route("/user", get(users::user_get).post(users::user_post)) .route("/user/balance", get(users::payment::user_balance_get)) .route( "/user/deposits/chain", get(users::payment::user_chain_deposits_get), ) .route( "/user/deposits/stripe", get(users::payment::user_stripe_deposits_get), ) .route( "/user/deposits/admin", get(users::payment::user_admin_deposits_get), ) .route( "/user/balance/stripe", post(users::payment_stripe::user_balance_stripe_post), ) .route( "/user/balance/:tx_hash", post(users::payment::user_balance_post), ) .route( "/user/balance_uncle/:uncle_hash", post(users::payment::user_balance_uncle_post), ) .route( "/user/keys", get(users::rpc_keys::rpc_keys_get) .post(users::rpc_keys::rpc_keys_management) .put(users::rpc_keys::rpc_keys_management), ) // .route("/user/referral/:referral_link", get(users::user_referral_link_get)) .route( "/user/referral", get(users::referral::user_referral_link_get), ) .route( "/user/referral/stats/used-codes", get(users::referral::user_used_referral_stats), ) .route( "/user/referral/stats/shared-codes", get(users::referral::user_shared_referral_stats), ) .route("/user/revert_logs", get(users::stats::user_revert_logs_get)) .route( "/user/stats/aggregate", get(users::stats::user_influx_stats_aggregated_get), ) .route( "/user/stats/aggregated", get(users::stats::user_influx_stats_aggregated_get), ) .route( "/user/stats/accounting", get(users::stats::user_mysql_stats_get), ) .route( "/user/stats/detailed", get(users::stats::user_influx_stats_detailed_get), ) .route( "/user/logout", post(users::authentication::user_logout_post), ) .route( "/admin/increase_balance", post(admin::admin_increase_balance), ) .route("/admin/modify_role", post(admin::admin_change_user_roles)) .route( "/admin/imitate_login/:admin_address/:user_address", get(admin::admin_imitate_login_get), ) .route( "/admin/imitate_login/:admin_address/:user_address/:message_eip", get(admin::admin_imitate_login_get), ) .route( "/admin/imitate_login", post(admin::admin_imitate_login_post), ) // // Axum layers // layers are ordered bottom up // the last layer is first for requests and last for responses // // Remove trailing slashes .layer(NormalizePathLayer::trim_trailing_slash()) // Mark the `Authorization` request header as sensitive so it doesn't show in logs .layer(SetSensitiveRequestHeadersLayer::new(once(AUTHORIZATION))) // handle cors. we expect queries from all sorts of places .layer(CorsLayer::very_permissive()) // application state .layer(Extension(app.clone())) // frontend caches .layer(Extension(Arc::new(response_cache))) // request timeout .layer( ServiceBuilder::new() // this middleware goes above `TimeoutLayer` because it will receive // errors returned by `TimeoutLayer` .layer(HandleErrorLayer::new(|_: BoxError| async { Web3ProxyError::Timeout(Some(Duration::from_secs(5 * 60))) })) .layer(TimeoutLayer::new(Duration::from_secs(5 * 60))), ) // request id .layer( TraceLayer::new_for_http().make_span_with(|request: &Request| { // We get the request id from the header // If no header, a new Ulid is created // TODO: move this header name to config let request_id = request .headers() .get("x-amzn-trace-id") .and_then(|x| x.to_str().ok()) .map(ToString::to_string) .unwrap_or_else(|| Ulid::new().to_string()); // And then we put it along with other information into the `request` span // TODO: what other info should we attach? how can we attach an error and a tracing span here? error_span!( "request", id = %request_id, // method = %request.method(), // // don't log the path. it often includes the RPC key! // path = %request.uri().path(), ) }), // .on_failure(|| todo!("on failure that has the request and response body so we can debug more easily")), ) // 404 for any unknown routes .fallback(errors::handler_404); // TODO: https://docs.rs/tower-http/latest/tower_http/propagate_header/index.html let server_builder = if let Some(listener) = ListenFd::from_env().take_tcp_listener(0)? { // use systemd socket magic for no downtime deploys let addr = listener.local_addr()?; info!("listening with fd at {}", addr); axum::Server::from_tcp(listener)? } else { // TODO: allow only listening on localhost? top_config.app.host.parse()? let addr = SocketAddr::from(([0, 0, 0, 0], app.frontend_port.load(Ordering::Relaxed))); axum::Server::try_bind(&addr)? }; // into_make_service is enough if we always run behind a proxy /* It sequentially looks for an IP in: - x-forwarded-for header (de-facto standard) - x-real-ip header - forwarded header (new standard) - axum::extract::ConnectInfo (if not behind proxy) */ let make_service = { info!("connectinfo feature enabled"); router.into_make_service_with_connect_info::() }; // #[cfg(not(feature = "connectinfo"))] // let make_service = { // info!("connectinfo feature disabled"); // router.into_make_service() // }; // TODO: get settings from app config let server = server_builder .http2_keep_alive_timeout(Duration::from_secs(70)) .serve(make_service); let port = server.local_addr().port(); info!("listening on port {}", port); app.frontend_port.store(port, Ordering::Relaxed); let server = server // TODO: option to use with_connect_info. we want it in dev, but not when running behind a proxy, but not .with_graceful_shutdown(async move { let _ = shutdown_receiver.recv().await; }) .await .map_err(Into::into); let _ = shutdown_complete_sender.send(()); server }