From bc87674f5737b8996c75e7c172f3f4ce5549a17e Mon Sep 17 00:00:00 2001 From: stefan-mysten <135084671+stefan-mysten@users.noreply.github.com> Date: Tue, 10 Dec 2024 21:19:19 -0800 Subject: [PATCH] Changes for web faucet --- Cargo.lock | 4 + crates/sui-faucet/Cargo.toml | 4 + crates/sui-faucet/src/errors.rs | 9 + crates/sui-faucet/src/faucet/mod.rs | 7 + crates/sui-faucet/src/server.rs | 423 +++++++++++++++++++++++++++- crates/sui/src/client_commands.rs | 18 +- 6 files changed, 455 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e2ec17199542..9ff941d73cf12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13679,12 +13679,15 @@ dependencies = [ "eyre", "futures", "http 1.1.0", + "lazy_static", "mysten-metrics", "mysten-network", "parking_lot 0.12.1", "prometheus", + "reqwest 0.12.5", "scopeguard", "serde", + "serde_json", "shared-crypto", "sui-config", "sui-json-rpc-types", @@ -13705,6 +13708,7 @@ dependencies = [ "ttl_cache", "typed-store", "uuid 1.2.2", + "wiremock", ] [[package]] diff --git a/crates/sui-faucet/Cargo.toml b/crates/sui-faucet/Cargo.toml index 861ac0649731a..97ad35617b2ea 100644 --- a/crates/sui-faucet/Cargo.toml +++ b/crates/sui-faucet/Cargo.toml @@ -29,7 +29,9 @@ eyre.workspace = true tempfile.workspace = true parking_lot.workspace = true tonic.workspace = true +reqwest.workspace = true tower_governor = "0.4.3" +lazy_static = "1" sui-json-rpc-types.workspace = true sui-types.workspace = true @@ -45,6 +47,8 @@ mysten-network.workspace = true [dev-dependencies] test-cluster.workspace = true +wiremock.workspace = true +serde_json.workspace = true [[bin]] name = "sui-faucet" diff --git a/crates/sui-faucet/src/errors.rs b/crates/sui-faucet/src/errors.rs index 1538a5b7be956..6ad5b9ad58413 100644 --- a/crates/sui-faucet/src/errors.rs +++ b/crates/sui-faucet/src/errors.rs @@ -5,6 +5,12 @@ use thiserror::Error; #[derive(Error, Debug, PartialEq, Eq)] pub enum FaucetError { + #[error("For testnet tokens, please use the Web UI: https://faucet.testnet.sui.io")] + NoToken, + + #[error("Request limit exceeded. {0}")] + TooManyRequests(String), + #[error("Faucet cannot read objects from fullnode: {0}")] FullnodeReadingError(String), @@ -42,6 +48,9 @@ pub enum FaucetError { #[error("Internal error: {0}")] Internal(String), + + #[error("Invalid user agent: {0}")] + InvalidUserAgent(String), } impl FaucetError { diff --git a/crates/sui-faucet/src/faucet/mod.rs b/crates/sui-faucet/src/faucet/mod.rs index 1483123803318..18d23ca034d9a 100644 --- a/crates/sui-faucet/src/faucet/mod.rs +++ b/crates/sui-faucet/src/faucet/mod.rs @@ -125,6 +125,12 @@ pub struct FaucetConfig { #[clap(long, action = clap::ArgAction::Set, default_value_t = false)] pub batch_enabled: bool, + + /// Testnet faucet requires authentication via the Web UI at https://faucet.testnet.sui.io + /// This flag is used to indicate that the faucet is running on testnet and enables the + /// authentication check. + #[clap(long)] + pub testnet: bool, } impl Default for FaucetConfig { @@ -143,6 +149,7 @@ impl Default for FaucetConfig { batch_request_size: 500, ttl_expiration: 300, batch_enabled: false, + testnet: false, } } } diff --git a/crates/sui-faucet/src/server.rs b/crates/sui-faucet/src/server.rs index dc1585e87d066..3a05681a9733f 100644 --- a/crates/sui-faucet/src/server.rs +++ b/crates/sui-faucet/src/server.rs @@ -5,24 +5,24 @@ use crate::{ AppState, BatchFaucetResponse, BatchStatusFaucetResponse, FaucetConfig, FaucetError, FaucetRequest, FaucetResponse, RequestMetricsLayer, }; - use axum::{ error_handling::HandleErrorLayer, - extract::Path, - http::StatusCode, - response::IntoResponse, + extract::{ConnectInfo, Host, Path}, + http::{header::HeaderMap, StatusCode}, + response::{IntoResponse, Redirect}, routing::{get, post}, BoxError, Extension, Json, Router, }; -use http::Method; +use http::{header::USER_AGENT, HeaderValue, Method}; use mysten_metrics::spawn_monitored_task; use prometheus::Registry; use std::{ borrow::Cow, + collections::BTreeMap, net::{IpAddr, SocketAddr}, path::PathBuf, sync::Arc, - time::Duration, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use sui_config::SUI_CLIENT_CONFIG; use sui_sdk::wallet_context::WalletContext; @@ -35,12 +35,207 @@ use tracing::{info, warn}; use uuid::Uuid; use crate::faucet::Faucet; +use serde::Deserialize; +use std::sync::Mutex; + +use anyhow::bail; +use lazy_static::lazy_static; +use std::env; + +/// Interval to cleanup expired tokens +const CLEANUP_INTERVAL: u64 = 60; // 60 seconds +/// Maximum number of requests per IP address +const MAX_REQUESTS_PER_IP: u32 = 3; +/// Interval to reset the request count for each IP address +const RESET_TIME_INTERVAL_SECS: u64 = 12 * 3600; // 12 hours +const CLOUDFLARE_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; +const FAUCET_WEB_APP_URL: &str = "https://faucet.sui.io"; // make this lazy static env? + +lazy_static! { + static ref DISCORD_BOT: Option = env::var("DISCORD_BOT").ok(); +} + +lazy_static! { + static ref TURNSTILE_SECRET_KEY: Option = env::var("TURNSTILE_SECRET_KEY").ok(); +} + +type IPAddr = String; + +/// Keep track of every IP address' requests. +#[derive(Debug)] +struct RequestsManager { + pub data: Mutex>, + reset_time_interval_secs: u64, + max_requests_per_ip: u32, +} + +/// Request's metadata +#[derive(Debug, Clone)] +struct RequestInfo { + expires_at: u64, + requests_used: u32, + total_requests_allowed: u32, +} + +/// Struct to deserialize token verification response from Cloudflare +#[derive(Deserialize, Debug)] +struct TokenVerification { + success: bool, + #[serde(rename = "error-codes")] + error_codes: Vec, +} + +impl RequestsManager { + /// Initialize a new RequestsManager the default values. + fn new() -> Self { + Self { + data: Mutex::new(BTreeMap::new()), + reset_time_interval_secs: RESET_TIME_INTERVAL_SECS, + max_requests_per_ip: MAX_REQUESTS_PER_IP, + } + } + + #[cfg(test)] + fn new_with_limits(max_requests_per_ip: u32, reset_time_interval_secs: u64) -> Self { + Self { + data: Mutex::new(BTreeMap::new()), + reset_time_interval_secs, + max_requests_per_ip, + } + } + + /// Get the current timestamp in seconds + fn current_timestamp_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs() + } + + /// Checks if the user-agent is present, and if it is the expected discord bot user-agent. This + /// includes a unique password that the bot has set in the user-agent header. + fn is_discord_bot(&self, user_agent: Option<&HeaderValue>) -> Result { + if let (Some(discord_bot), Some(v)) = (DISCORD_BOT.as_ref(), user_agent) { + let header = v + .to_str() + .map_err(|e| FaucetError::InvalidUserAgent(e.to_string()))?; + Ok(header == format!("discord-bot-{}", discord_bot)) + } else { + Ok(false) + } + } + + /// Validates a token + /// - against Cloudflare turnstile's server to ensure token was issued by turnstile + /// - against the IP address' request count + async fn validate_token( + &self, + url: &str, + addr: SocketAddr, + token: &str, + ) -> Result<(), (StatusCode, FaucetError)> { + let turnstile_key = TURNSTILE_SECRET_KEY.as_ref().unwrap().as_str(); + let req = reqwest::Client::new(); + let params = [ + ("secret", turnstile_key), + ("response", token), + ("remoteip", &addr.ip().to_string()), + ]; + + // Make the POST request + let response = req.post(url).form(¶ms).send().await; + + match response { + Ok(resp) => { + if resp.status().is_success() { + let body = resp.json::().await; + if let Ok(body) = body { + // if body success is false, that means that token verification failed + // either because the token is invalid or the token has already been used + if !body.success { + return Err(( + StatusCode::BAD_REQUEST, + FaucetError::Internal(format!( + "Token verification failed: {:?}", + body.error_codes + )), + )); + } + } + + let current_time = Self::current_timestamp_secs(); + + // Check if the IP address is already in the map + let mut locked_data = self.data.lock().unwrap(); + let token_entry = locked_data.get_mut(&addr.ip().to_string()); + + if let Some(token_entry) = token_entry { + // Check IP address expiration time + if current_time > token_entry.expires_at { + locked_data.remove(token); + return Err(( + StatusCode::BAD_REQUEST, + FaucetError::Internal("Token expired".to_string()), + )); + } + + // Check request limit + if token_entry.requests_used >= token_entry.total_requests_allowed { + return Err(( + StatusCode::TOO_MANY_REQUESTS, + FaucetError::TooManyRequests(format!( + "You can request a new token in {}", + secs_to_human_readable(token_entry.expires_at - current_time) + )), + )); + } + // Increment request count + token_entry.requests_used += 1; + } else { + // Create new token entry + let token_info = RequestInfo { + expires_at: current_time + self.reset_time_interval_secs, // 12 hours + requests_used: 1, + total_requests_allowed: self.max_requests_per_ip, + }; + locked_data.insert(addr.ip().to_string(), token_info); + } + } else { + return Err(( + StatusCode::BAD_REQUEST, + FaucetError::Internal(format!("Invalid token")), + )); + } + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + FaucetError::Internal(format!("Internal server error: {:?}", e)), + )); + } + } + Ok(()) + } + + /// This function iterates through the stored IPs and removes those IP addresses which are now + /// eligible to make new requests. + fn cleanup_expired_tokens(&self) { + let current_time = Self::current_timestamp_secs(); + let mut data = self.data.lock().unwrap(); + + // keep only those IP addresses that are still under time limit. + data.retain(|_, info| current_time <= info.expires_at); + } +} pub async fn start_faucet( app_state: Arc, concurrency_limit: usize, prometheus_registry: &Registry, ) -> Result<(), anyhow::Error> { + if app_state.config.testnet && (DISCORD_BOT.is_none() || TURNSTILE_SECRET_KEY.is_none()) { + bail!("Both DISCORD_BOT and TURNSTILE_SECRET_KEY env vars must be set for testnet deployment (--testnet flag was set)"); + } // TODO: restrict access if needed let cors = CorsLayer::new() .allow_methods(vec![Method::GET, Method::POST]) @@ -63,9 +258,11 @@ pub async fn start_faucet( .finish() .unwrap(), ); + let token_manager = Arc::new(RequestsManager::new()); let app = Router::new() - .route("/", get(health)) + .route("/", get(redirect)) + .route("/health", get(health)) .route("/gas", post(request_gas)) .route("/v1/gas", post(batch_request_gas)) .route("/v1/status/:task_id", get(request_status)) @@ -81,6 +278,7 @@ pub async fn start_faucet( }) .concurrency_limit(concurrency_limit) .layer(Extension(app_state.clone())) + .layer(Extension(token_manager.clone())) .into_inner(), ); @@ -93,10 +291,22 @@ pub async fn start_faucet( } }); + spawn_monitored_task!(async move { + info!("Starting task to clear banned ip addresses."); + loop { + tokio::time::sleep(Duration::from_secs(CLEANUP_INTERVAL)).await; + token_manager.cleanup_expired_tokens(); + } + }); + let addr = SocketAddr::new(IpAddr::V4(host_ip), port); info!("listening on {}", addr); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app).await?; + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await?; Ok(()) } @@ -105,8 +315,24 @@ async fn health() -> &'static str { "OK" } +/// Redirect to faucet.sui.io/?network if it's testnet/devnet network +async fn redirect(Host(host): Host) -> impl IntoResponse { + if host.contains("testnet") { + let redirect = Redirect::to(&format!("{FAUCET_WEB_APP_URL}/?network=testnet")); + redirect.into_response() + } else if host.contains("devnet") { + let redirect = Redirect::to(&format!("{FAUCET_WEB_APP_URL}/?network=devnet")); + redirect.into_response() + } else { + health().await.into_response() + } +} + /// handler for batch_request_gas requests async fn batch_request_gas( + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, + Extension(token_manager): Extension>, Extension(state): Extension>, Json(payload): Json, ) -> impl IntoResponse { @@ -114,6 +340,41 @@ async fn batch_request_gas( // ID for traceability info!(uuid = ?id, "Got new gas request."); + // If this service is running for testnet and it is not the discord bot, users need to use the + // WebUI to request tokens and we need to validate the CloudFlare turnstile token here + + if state.config.testnet { + // Check if the user-agent is present, and if it is the expected discord bot user-agent. + let is_discord_bot = match token_manager.is_discord_bot(headers.get(USER_AGENT)) { + Ok(bot) => bot, + Err(err) => { + return ( + StatusCode::BAD_REQUEST, + Json(BatchFaucetResponse::from(err)), + ); + } + }; + + if !is_discord_bot { + let Some(token) = headers + .get("X-Turnstile-Token") + .and_then(|v| v.to_str().ok()) + else { + return ( + StatusCode::BAD_REQUEST, + Json(BatchFaucetResponse::from(FaucetError::NoToken)), + ); + }; + + let validation = token_manager + .validate_token(CLOUDFLARE_URL, addr, token) + .await; + if let Err((status_code, faucet_error)) = validation { + return (status_code, Json(BatchFaucetResponse::from(faucet_error))); + } + } + } + let FaucetRequest::FixedAmountRequest(request) = payload else { return ( StatusCode::BAD_REQUEST, @@ -212,12 +473,45 @@ async fn request_status( /// handler for all the request_gas requests async fn request_gas( + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, + Extension(token_manager): Extension>, Extension(state): Extension>, Json(payload): Json, ) -> impl IntoResponse { // ID for traceability let id = Uuid::new_v4(); info!(uuid = ?id, "Got new gas request."); + + if state.config.testnet { + // Check if the user-agent is present, and if it is the expected discord bot user-agent. + let is_discord_bot = match token_manager.is_discord_bot(headers.get(USER_AGENT)) { + Ok(bot) => bot, + Err(err) => { + return (StatusCode::BAD_REQUEST, Json(FaucetResponse::from(err))); + } + }; + + if !is_discord_bot { + let Some(token) = headers + .get("X-Turnstile-Token") + .and_then(|v| v.to_str().ok()) + else { + return ( + StatusCode::BAD_REQUEST, + Json(FaucetResponse::from(FaucetError::NoToken)), + ); + }; + + let validation = token_manager + .validate_token(CLOUDFLARE_URL, addr, token) + .await; + if let Err((status_code, faucet_error)) = validation { + return (status_code, Json(FaucetResponse::from(faucet_error))); + } + } + } + let result = match payload { FaucetRequest::FixedAmountRequest(requests) => { // We spawn a tokio task for this such that connection drop will not interrupt @@ -285,3 +579,116 @@ async fn handle_error(error: BoxError) -> impl IntoResponse { Cow::from(format!("Unhandled internal error: {}", error)), ) } + +/// Format seconds to human readable format. +fn secs_to_human_readable(total_seconds: u64) -> String { + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + let seconds = total_seconds % 60; + + if hours > 0 { + format!("{}h {}m {}s", hours, minutes, seconds) + } else if minutes > 0 { + format!("{}m {}s", minutes, seconds) + } else { + format!("{}s", seconds) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::net::{IpAddr, Ipv4Addr}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + async fn setup_mock_cloudflare() -> MockServer { + std::env::set_var("TURNSTILE_SECRET_KEY", "test_secret"); + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&mock_server) + .await; + + mock_server + } + + #[tokio::test] + async fn test_token_validation_and_limits() { + // Start mock server + let mock_server = setup_mock_cloudflare().await; + let manager = RequestsManager::new(); + let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080); + let token = "test_token"; + + // First request should succeed + let result = manager.validate_token(&mock_server.uri(), ip, token).await; + assert!(result.is_ok()); + + // Use up remaining requests + for _ in 1..manager.max_requests_per_ip { + let result = manager.validate_token(&mock_server.uri(), ip, token).await; + assert!(result.is_ok()); + } + + // Next request should fail due to limit + let result = manager.validate_token(&mock_server.uri(), ip, token).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_token_reset_after_interval() { + let mock_server = setup_mock_cloudflare().await; + let reset_time_interval_secs = 5; // seconds for testing + let manager = + RequestsManager::new_with_limits(MAX_REQUESTS_PER_IP, reset_time_interval_secs); + + let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080); + let token = "test_token"; + + // Use up all requests + for _ in 0..manager.max_requests_per_ip { + let result = manager.validate_token(&mock_server.uri(), ip, token).await; + assert!(result.is_ok()); + } + + // Try one more, it should fail + let result = manager.validate_token(&mock_server.uri(), ip, token).await; + assert!(result.is_err()); + assert!(result.unwrap_err().0 == StatusCode::TOO_MANY_REQUESTS); + assert!(!manager.data.lock().unwrap().is_empty()); + + tokio::time::sleep(Duration::from_secs(reset_time_interval_secs + 1)).await; + // Trigger cleanup + manager.cleanup_expired_tokens(); + + // Should be able to make new requests + let result = manager.validate_token(&mock_server.uri(), ip, token).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_invalid_token_response() { + let mock_server = MockServer::start().await; + std::env::set_var("TURNSTILE_SECRET_KEY", "test_secret"); + + // Setup mock for invalid token + Mock::given(method("POST")) + .and(path("/siteverify")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "success": false, + "error-codes": ["invalid-input-response"] + }))) + .mount(&mock_server) + .await; + + let manager = RequestsManager::new(); + let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080); + let token = "invalid_token"; + + let result = manager.validate_token(&mock_server.uri(), ip, token).await; + assert!(result.is_err()); + } +} diff --git a/crates/sui/src/client_commands.rs b/crates/sui/src/client_commands.rs index 368e22328c4c0..2c9865abd24b0 100644 --- a/crates/sui/src/client_commands.rs +++ b/crates/sui/src/client_commands.rs @@ -665,7 +665,7 @@ impl OptsWithGas { } } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Debug)] struct FaucetResponse { error: Option, } @@ -1444,6 +1444,12 @@ impl SuiClientCommands { SuiClientCommands::Faucet { address, url } => { let address = get_identity_address(address, context)?; let url = if let Some(url) = url { + if url.starts_with("https://faucet.testnet.sui.io") { + bail!( + "For testnet tokens, please use the Web UI: https://faucet.testnet.sui.io/?address={address}" + ) + } + url } else { let active_env = context.config.get_active_env(); @@ -1451,7 +1457,9 @@ impl SuiClientCommands { if let Ok(env) = active_env { let network = match env.rpc.as_str() { SUI_DEVNET_URL => "https://faucet.devnet.sui.io/v1/gas", - SUI_TESTNET_URL => "https://faucet.testnet.sui.io/v1/gas", + SUI_TESTNET_URL => { + bail!("For testnet tokens, please use the Web UI: https://faucet.testnet.sui.io/?address={address}"); + } SUI_LOCAL_NETWORK_URL | SUI_LOCAL_NETWORK_URL_0 => "http://127.0.0.1:9123/gas", _ => bail!("Cannot recognize the active network. Please provide the gas faucet full URL.") }; @@ -2569,6 +2577,12 @@ pub async fn request_tokens_from_faucet( println!("Request successful. It can take up to 1 minute to get the coin. Run sui client gas to check your gas coins."); } } + StatusCode::BAD_REQUEST => { + let faucet_resp: FaucetResponse = resp.json().await?; + if let Some(err) = faucet_resp.error { + bail!("Faucet request was unsuccessful. {err}"); + } + } StatusCode::TOO_MANY_REQUESTS => { bail!("Faucet service received too many requests from this IP address. Please try again after 60 minutes."); }