http.rs (11334B)
1 use std::{ 2 collections::BTreeMap, 3 net::SocketAddr, 4 sync::Arc, 5 time::{Instant, SystemTime, UNIX_EPOCH}, 6 }; 7 8 use anyhow::{Context, Result}; 9 use axum::{ 10 Form, Json, Router, 11 body::Body, 12 extract::State, 13 http::Request, 14 middleware, 15 middleware::Next, 16 response::Response, 17 routing::{get, post}, 18 }; 19 use tokio::{net::TcpListener, sync::Mutex}; 20 21 use crate::{ 22 adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork, ui_index::UiChainIndex}, 23 app::{SharedNode, SharedPeerBook}, 24 domain::validate_address, 25 }; 26 27 mod actions; 28 mod api; 29 mod auth; 30 mod auth_routes; 31 mod index_html; 32 mod metrics; 33 mod request_auth; 34 mod state; 35 mod static_assets; 36 mod ui; 37 mod wallet; 38 mod wallet_persistence; 39 #[cfg(test)] 40 use actions::{ 41 add_peer, persist_burn_settings_config, persist_pow_mining_config, remove_address_book_entry, 42 remove_peer, reset_local_chain, set_burn_settings, set_keep_track_of_metrics, 43 set_p2p_accept_inbound, set_p2p_announce_addr, upsert_address_book_entry, 44 }; 45 use actions::{ 46 api_address_book_delete_form, api_address_book_form, api_burn_fee_estimate_form, 47 api_burn_per_block_form, api_chain_reset_form, api_metrics_settings_form, 48 api_mine_fee_estimate_form, api_p2p_announce_form, api_p2p_inbound_form, api_peer_delete_form, 49 api_peer_form, api_pow_mining_form, api_recovery_vdf_settings_form, 50 api_transfer_fee_estimate_form, api_transfer_form, api_wallet_generate_form, 51 api_wallet_import_form, apply_config_form, burn_per_block_form, peer_form, transfer_form, 52 }; 53 use api::{ 54 api_blocks, api_config, api_mempool, api_metrics, api_network_health, api_p2p_metrics, 55 api_peers, api_status, api_wallet_selectable_utxos, api_wallet_transactions, api_wallet_utxos, 56 }; 57 #[cfg(test)] 58 use api::{page_items, selectable_wallet_utxo_rows, wallet_utxo_rows}; 59 #[cfg(test)] 60 use auth::{hash_password, hex_encode, pbkdf2_sha256, validate_password, verify_password}; 61 use auth_routes::{ 62 api_auth_change_password_form, api_auth_login_form, api_auth_logout_form, api_auth_setup_form, 63 api_auth_status, require_auth_middleware, 64 }; 65 use index_html::INDEX_HTML; 66 #[cfg(test)] 67 use metrics::network_health_at; 68 use metrics::{metrics_response, network_health}; 69 use request_auth::wallet_password_for_request; 70 #[cfg(test)] 71 use request_auth::{auth_client_key, login_auth_password, same_origin_request}; 72 pub use state::ServeOptions; 73 use state::{AuthClientKey, AuthSession, HttpState, UiChainCache, UiChainView}; 74 use static_assets::{alpine_js, app_js, favicon, index}; 75 use ui::{ 76 add_pending_outputs, cached_chain_view, cached_ui_blocks_for_tip, ui_blinded_reveal, 77 ui_blinded_transaction, ui_blocks_from_indexes, ui_pending_revealed_transaction, 78 ui_transaction, wallet_transaction_row, wallet_transaction_rows, 79 }; 80 #[cfg(test)] 81 use ui::{known_output_index, revealed_transactions_by_height, ui_block, ui_blocks}; 82 use wallet::{ 83 api_wallet_setup, estimate_burn_fee, estimate_mine_fee, estimate_transfer_fee, 84 fee_estimate_json, import_setup_wallet_seed, replace_setup_wallet_with_generated_seed, 85 required_fee_per_byte_burn, setup_requires_peer, transfer, wallet_setup_json, 86 }; 87 #[cfg(test)] 88 use wallet::{dev_seed_verify_bypass_allowed, validate_transfer_form}; 89 use wallet_persistence::run_owned_blinded_outbox_persistence; 90 91 const EXPLORER_LIMIT: usize = 50; 92 const EXPLORER_PAGE_LIMIT: usize = 20; 93 const DATASET_LIMIT: usize = 1_000; 94 const DATASET_PAGE_LIMIT: usize = 25; 95 const AUTH_COOKIE_NAME: &str = "iuna_session"; 96 const AUTH_SESSION_TTL_MS: u64 = 12 * 60 * 60 * 1_000; 97 const AUTH_MAX_FAILED_ATTEMPTS: u32 = 5; 98 const AUTH_LOCKOUT_MS: u64 = 60 * 1_000; 99 const UNKNOWN_CLIENT_KEY: &str = "unknown"; 100 const PEER_STALE_AFTER_MS: u64 = 20 * 60 * 1_000; 101 const SLOW_UI_REQUEST_LOG_MS: u128 = 250; 102 103 mod types; 104 use types::{ 105 ActionResponse, AuthForm, AuthStatusResponse, BlocksQuery, ChangePasswordForm, ConfigForm, 106 ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse, NetworkHealthLocalState, 107 NetworkHealthResponse, Page, PageQuery, UiBlock, UiTransaction, WalletTransactionContext, 108 WalletTransactionFilters, WalletTransactionRow, WalletTransactionsQuery, WalletUtxoRow, 109 }; 110 #[cfg(test)] 111 use types::{BurnSettingsForm, TransferForm}; 112 113 pub async fn serve( 114 node: SharedNode, 115 peers: SharedPeerBook, 116 gossip: GossipNetwork, 117 ui_config: Arc<Mutex<UiConfig>>, 118 options: ServeOptions, 119 ) -> Result<()> { 120 let addr = options.addr; 121 let state = HttpState { 122 node, 123 peers, 124 gossip, 125 ui_config, 126 config_path: options.config_path, 127 chain_store: options.chain_store, 128 ui_data_store: options.ui_data_store, 129 wallet_path: options.wallet_path, 130 stratum: options.stratum, 131 auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), 132 auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), 133 ui_cache: Arc::new(Mutex::new(UiChainCache::default())), 134 ui_data_refresh: Arc::new(Mutex::new(())), 135 }; 136 println!( 137 "warming UI data cache from {}...", 138 state.ui_data_store.path().display() 139 ); 140 let ui_data_started = Instant::now(); 141 prewarm_chain_view_cache(state.clone()).await?; 142 { 143 let cache = state.ui_cache.lock().await; 144 println!( 145 "UI data cache ready in {:.2}s (outputs: {}, revealed heights: {}, burn-rank blocks: {})", 146 ui_data_started.elapsed().as_secs_f64(), 147 cache.outputs.len(), 148 cache.revealed_by_height.len(), 149 cache.burn_leader_ranks_by_hash.len() 150 ); 151 } 152 tokio::spawn(run_owned_blinded_outbox_persistence(state.clone())); 153 let app = Router::new() 154 .route("/", get(index)) 155 .route("/favicon.ico", get(favicon)) 156 .route("/assets/alpine.min.js", get(alpine_js)) 157 .route("/assets/iuna-ui.js", get(app_js)) 158 .route("/api/auth/status", get(api_auth_status)) 159 .route("/api/auth/setup", post(api_auth_setup_form)) 160 .route("/api/auth/login", post(api_auth_login_form)) 161 .route("/api/auth/logout", post(api_auth_logout_form)) 162 .route( 163 "/api/auth/change-password", 164 post(api_auth_change_password_form), 165 ) 166 .route("/api/status", get(api_status)) 167 .route("/api/blocks", get(api_blocks)) 168 .route("/api/config", get(api_config).post(api_config_form)) 169 .route( 170 "/api/address-book", 171 post(api_address_book_form).delete(api_address_book_delete_form), 172 ) 173 .route("/api/wallet/setup", get(api_wallet_setup)) 174 .route("/api/wallet/generate", post(api_wallet_generate_form)) 175 .route("/api/wallet/import", post(api_wallet_import_form)) 176 .route("/api/wallet/transactions", get(api_wallet_transactions)) 177 .route( 178 "/api/wallet/utxos/selectable", 179 get(api_wallet_selectable_utxos), 180 ) 181 .route("/api/wallet/utxos", get(api_wallet_utxos)) 182 .route( 183 "/api/fee-estimate/transfer", 184 post(api_transfer_fee_estimate_form), 185 ) 186 .route("/api/fee-estimate/burn", post(api_burn_fee_estimate_form)) 187 .route("/api/fee-estimate/mine", post(api_mine_fee_estimate_form)) 188 .route("/api/mempool", get(api_mempool)) 189 .route("/api/metrics", get(api_metrics)) 190 .route("/api/network/health", get(api_network_health)) 191 .route( 192 "/api/peers", 193 get(api_peers) 194 .post(api_peer_form) 195 .delete(api_peer_delete_form), 196 ) 197 .route("/api/p2p/metrics", get(api_p2p_metrics)) 198 .route( 199 "/api/settings/burn-per-block", 200 post(api_burn_per_block_form), 201 ) 202 .route("/api/settings/pow-mining", post(api_pow_mining_form)) 203 .route("/api/settings/metrics", post(api_metrics_settings_form)) 204 .route( 205 "/api/settings/recovery-vdf", 206 post(api_recovery_vdf_settings_form), 207 ) 208 .route("/api/settings/chain-reset", post(api_chain_reset_form)) 209 .route("/api/settings/p2p-inbound", post(api_p2p_inbound_form)) 210 .route("/api/settings/p2p-announce", post(api_p2p_announce_form)) 211 .route("/api/transfer", post(api_transfer_form)) 212 .route("/settings/burn-per-block", post(burn_per_block_form)) 213 .route("/transfer", post(transfer_form)) 214 .route("/peers", post(peer_form)) 215 .layer(middleware::from_fn_with_state( 216 state.clone(), 217 require_auth_middleware, 218 )) 219 .layer(middleware::from_fn(log_slow_api_request)) 220 .with_state(state); 221 222 let listener = TcpListener::bind(addr) 223 .await 224 .with_context(|| format!("binding HTTP management UI on {addr}"))?; 225 axum::serve( 226 listener, 227 app.into_make_service_with_connect_info::<SocketAddr>(), 228 ) 229 .await 230 .context("serving HTTP management UI") 231 } 232 233 async fn log_slow_api_request(request: Request<Body>, next: Next) -> Response { 234 let method = request.method().clone(); 235 let path = request.uri().path().to_string(); 236 let started = Instant::now(); 237 let response = next.run(request).await; 238 let elapsed = started.elapsed(); 239 if path.starts_with("/api/") && elapsed.as_millis() >= SLOW_UI_REQUEST_LOG_MS { 240 println!( 241 "slow UI API request: {method} {path} -> {} in {:.2}s", 242 response.status().as_u16(), 243 elapsed.as_secs_f64() 244 ); 245 } 246 response 247 } 248 249 async fn prewarm_chain_view_cache(state: HttpState) -> Result<()> { 250 let tip_hash = { 251 let node = state.node.lock().await; 252 node.chain_tip_hash() 253 }; 254 if let Some(index) = load_persisted_ui_chain_index(&state, tip_hash.clone()).await? { 255 let mut cache = state.ui_cache.lock().await; 256 cache.tip_hash = index.tip_hash; 257 cache.outputs = index.outputs; 258 cache.revealed_by_height = index.revealed_by_height; 259 cache.burn_leader_ranks_by_hash = index.burn_leader_ranks_by_hash; 260 return Ok(()); 261 } 262 263 let snapshot = { 264 let node = state.node.lock().await; 265 node.chain_snapshot() 266 }; 267 let _ = cached_chain_view(&state, &snapshot).await?; 268 Ok(()) 269 } 270 271 async fn load_persisted_ui_chain_index( 272 state: &HttpState, 273 tip_hash: String, 274 ) -> Result<Option<UiChainIndex>> { 275 let store = state.ui_data_store.clone(); 276 tokio::task::spawn_blocking(move || store.load_ui_chain_index(&tip_hash)) 277 .await 278 .context("UI chain index loader failed")? 279 } 280 281 async fn api_config_form( 282 State(state): State<HttpState>, 283 Form(form): Form<ConfigForm>, 284 ) -> Json<ActionResponse> { 285 action_json(apply_config_form(&state, form).await) 286 } 287 288 fn action_json(result: Result<()>) -> Json<ActionResponse> { 289 match result { 290 Ok(_) => Json(ActionResponse { 291 ok: true, 292 error: None, 293 }), 294 Err(error) => Json(ActionResponse { 295 ok: false, 296 error: Some(format!("{error:#}")), 297 }), 298 } 299 } 300 301 fn api_error(error: anyhow::Error) -> Json<ActionResponse> { 302 Json(ActionResponse { 303 ok: false, 304 error: Some(format!("{error:#}")), 305 }) 306 } 307 308 fn now_ms() -> u64 { 309 SystemTime::now() 310 .duration_since(UNIX_EPOCH) 311 .unwrap_or_default() 312 .as_millis() as u64 313 } 314 315 #[cfg(test)] 316 mod tests;