diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c217baa --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "grin-faucet" +version = "1.0.0" +edition = "2024" + +[dependencies] +warp = { version = "0.3", features = ["tls"] } +tokio = { version = "1", features = ["full"] } +tokio-rustls = "0.22" +serde = { version = "1.0", features = ["derive"] } +chrono = "0.4" +simplelog = "0.11" +log = "0.4" +sha2 = "0.10" +hex = "0.4.3" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..60e4afb --- /dev/null +++ b/src/main.rs @@ -0,0 +1,201 @@ +use chrono::{Duration, Local}; +use log::{LevelFilter, error, info}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use simplelog::{ColorChoice, CombinedLogger, Config, TermLogger, TerminalMode, WriteLogger}; +use std::collections::HashMap; +use std::fs::File; +use std::process::Command; +use std::sync::{Arc, Mutex}; +use warp::Filter; + +#[derive(Deserialize)] +struct SendRequest { + address: String, +} + +#[derive(Serialize)] +struct Response { + message: String, +} + +struct RateLimiter { + last_sent_ip: HashMap>, + last_sent_address: HashMap>, +} + +fn is_valid_address(address: &str) -> bool { + if address.is_empty() + || address.contains(' ') + || !address.starts_with("grin1") + || !address.chars().all(|c| c.is_alphanumeric()) + || address.len() < 62 + { + return false; + } + true +} + +// Function to hash the IP address +fn hash_ip(ip: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(ip); + let result = hasher.finalize(); + hex::encode(result) // Convert the hash to a hexadecimal string +} + +#[tokio::main] +async fn main() { + // Initialize logging + let log_file = File::create("faucet.log").unwrap(); + CombinedLogger::init(vec![ + TermLogger::new( + LevelFilter::Info, + Config::default(), + TerminalMode::Mixed, + ColorChoice::Always, + ), + WriteLogger::new(LevelFilter::Info, Config::default(), log_file), + ]) + .unwrap(); + + let rate_limiter = Arc::new(Mutex::new(RateLimiter { + last_sent_ip: HashMap::new(), + last_sent_address: HashMap::new(), + })); + + let rate_limiter_filter = warp::any().map(move || rate_limiter.clone()); + + let send_faucet = warp::post() + .and(warp::path("send")) + .and(warp::body::json()) + .and(rate_limiter_filter.clone()) + .and(warp::addr::remote()) // Get the remote address + .map( + |request: SendRequest, + rate_limiter: Arc>, + remote_addr: Option| { + let address = request.address; + + // Validate the address + if !is_valid_address(&address) { + return warp::reply::json(&Response { + message: + "Invalid: Must start with 'grin1' and be a valid 62 character address" + .to_string(), + }); + } + + let mut rate_limiter = rate_limiter.lock().unwrap(); + let now = Local::now(); + + // Hash the IP address + let ip_hash = match remote_addr { + Some(addr) => hash_ip(&addr.ip().to_string()), + None => { + return warp::reply::json(&Response { + message: "Could not retrieve IP address".to_string(), + }); + } + }; + + info!("IP Hash: {}", ip_hash); + + // Check if the IP address has been sent funds in the last 24 hours + if let Some(last_sent) = rate_limiter.last_sent_ip.get(&ip_hash) { + if now - *last_sent < Duration::hours(24) { + info!("Criminal {} rate limited.", ip_hash); + return warp::reply::json(&Response { + message: "You can only request 1ツ every 24 hours".to_string(), + }); + } + } + + // Check if the wallet address has been sent funds in the last 24 hours + if let Some(last_sent) = rate_limiter.last_sent_address.get(&address) { + if now - *last_sent < Duration::hours(24) { + info!("Wallet {} rate limited.", address); + return warp::reply::json(&Response { + message: "This wallet address can only request 1ツ every 24 hours".to_string(), + }); + } + } + + // Execute the command + let output = Command::new("bash") + .arg("-c") + .arg(format!( + "echo '' | /usr/local/bin/grin-wallet send -d {} 1", + address + )) + .output() + .expect("Failed to execute command"); + + // Update the last sent time for both IP and wallet address + rate_limiter.last_sent_ip.insert(ip_hash.clone(), now); + rate_limiter.last_sent_address.insert(address.clone(), now); + + // Handle command output + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if stderr.is_empty() { + if let Some(slatepack_message) = extract_slatepack_message(&stdout) { + info!(" {}", slatepack_message); + return warp::reply::json(&Response { + message: slatepack_message, + }); + } else { + info!("Grin sent successfully to address: {}", address); + return warp::reply::json(&Response { + message: "Grin sent via TOR ツ".to_string(), + }); + } + } else { + error!("Error sending funds to address {}: {}", address, stderr); + return warp::reply::json(&Response { + message: format!("Error: {}", stderr), + }); + } + }, + ); + + // Load SSL keys and certs + let cert_path = "/etc/ssl/cert.pem"; + let key_path = "/etc/ssl/privkey.pem"; + + // Enable CORS only from this site + let cors = warp::cors() + .allow_origin("https://") + .allow_methods(vec!["POST"]) // Allow POST requests + .allow_headers(vec!["Content-Type"]); // Allow Content-Type header + + // Start the warp server with CORS & TLS + warp::serve(send_faucet.with(cors)) + .tls() + .cert_path(cert_path) + .key_path(key_path) + .run(([0, 0, 0, 0], 3031)) // Listen on all interfaces + .await; +} + +// Function to extract the slatepack message from the output +fn extract_slatepack_message(stdout: &str) -> Option { + let start_marker = "BEGINSLATEPACK."; + let end_marker = "ENDSLATEPACK."; + + if let Some(start) = stdout.find(start_marker) { + if let Some(end) = stdout.find(end_marker) { + let slatepack_message = &stdout[start..end + end_marker.len()]; + + let trimmed_message = if slatepack_message.starts_with(' ') { + &slatepack_message[1..] // Remove the first character (space) + } else { + slatepack_message // Return the original message if no leading space + }; + + return Some(trimmed_message.to_string()); + } + } + None +}