iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

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;