Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/hub/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod configuration;
pub mod faucet;
pub mod graphql;
pub mod metric;
pub mod rate_limit;
pub mod referrer;
pub mod seq;
pub mod server;
Expand Down
101 changes: 101 additions & 0 deletions src/hub/rate_limit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! In-memory cooldown limiter for the testnet faucet.
//! Keyed by client IP (anti-spam) and recipient address (anti-refund drain).

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

pub struct FaucetRateLimiter {
// ponytail: one map, "ip:"/"addr:" prefixed keys; pruned to the per-address
// horizon on each call so it stays bounded — fine for testnet volume. LRU if it grows.
hits: Mutex<HashMap<String, Instant>>,
per_ip: Duration,
per_address: Duration,
}

impl FaucetRateLimiter {
// ponytail: fixed cooldowns; move to AppConfig if operators need to tune them.
pub fn new() -> Self {
Self::with_windows(Duration::from_secs(30), Duration::from_secs(3600))
}

pub fn with_windows(per_ip: Duration, per_address: Duration) -> Self {
Self {
hits: Mutex::new(HashMap::new()),
per_ip,
per_address,
}
}

/// Record a hit for (ip, address). Returns `Err(retry_after_secs)` if either the IP
/// or the address is still cooling down; on rejection the hit is NOT recorded.
pub fn check(&self, ip: &str, address: &str) -> Result<(), u64> {
let now = Instant::now();
// Recover a poisoned lock rather than wedging the faucet forever.
let mut hits = self.hits.lock().unwrap_or_else(|p| p.into_inner());

// Drop entries older than the longest window so the map stays bounded.
hits.retain(|_, t| now.saturating_duration_since(*t) < self.per_address);

let ip_key = format!("ip:{}", ip);
let addr_key = format!("addr:{}", address.to_lowercase());

if let Some(retry) = Self::remaining(&hits, &ip_key, self.per_ip, now) {
return Err(retry);
}
if let Some(retry) = Self::remaining(&hits, &addr_key, self.per_address, now) {
return Err(retry);
}

hits.insert(ip_key, now);
hits.insert(addr_key, now);
Ok(())
}

fn remaining(
hits: &HashMap<String, Instant>,
key: &str,
window: Duration,
now: Instant,
) -> Option<u64> {
let last = hits.get(key)?;
let elapsed = now.saturating_duration_since(*last);
if elapsed < window {
Some((window - elapsed).as_secs() + 1)
} else {
None
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::thread::sleep;

#[test]
fn blocks_rapid_repeat_then_allows_after_window() {
let rl = FaucetRateLimiter::with_windows(
Duration::from_millis(40),
Duration::from_millis(40),
);
// First hit allowed.
assert!(rl.check("1.2.3.4", "0xabc").is_ok());
// Immediate repeat from the same IP is blocked, even for a different address.
assert!(rl.check("1.2.3.4", "0xother").is_err());
// Same address from a different IP is blocked by the address window.
assert!(rl.check("9.9.9.9", "0xabc").is_err());
// After the window elapses, allowed again.
sleep(Duration::from_millis(60));
assert!(rl.check("1.2.3.4", "0xabc").is_ok());
}

#[test]
fn address_key_is_case_insensitive() {
let rl =
FaucetRateLimiter::with_windows(Duration::from_secs(10), Duration::from_secs(10));
assert!(rl.check("1.1.1.1", "0xABC").is_ok());
// Different IP, same address in different case — still blocked.
assert!(rl.check("2.2.2.2", "0xabc").is_err());
}
}
23 changes: 22 additions & 1 deletion src/hub/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ use crate::hub::configuration::AppConfig;
use crate::hub::faucet::execute_faucet;
use crate::hub::graphql::build_schema;
use crate::hub::graphql::handler::{graphql_handler, graphql_ws_handler};
use crate::hub::rate_limit::FaucetRateLimiter;
use crate::hub::signature_keys::SignatureKeys;
use actix_cors::Cors;
use actix_web::{web, App, HttpResponse, HttpServer, Result};
use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Result};
use serde::Deserialize;
use std::sync::Arc;

Expand All @@ -29,9 +30,11 @@ pub struct FaucetRequestBody {
}

async fn post_faucet(
req: HttpRequest,
body: web::Json<FaucetRequestBody>,
client: web::Data<Arc<ClutchNodeClient>>,
config: web::Data<AppConfig>,
rate_limiter: web::Data<FaucetRateLimiter>,
) -> HttpResponse {
if !config.faucet_enabled {
return HttpResponse::ServiceUnavailable().json(serde_json::json!({
Expand All @@ -46,6 +49,21 @@ async fn post_faucet(
if let Err(e) = SignatureKeys::validate_public_key(&body.address) {
return HttpResponse::BadRequest().json(serde_json::json!({ "error": e }));
}
// Real client IP behind the reverse proxy (X-Forwarded-For / X-Real-IP), else socket peer.
// ponytail: XFF is client-spoofable if not behind a trusted proxy; the deploy runs nginx.
let client_ip = req
.connection_info()
.realip_remote_addr()
.unwrap_or("unknown")
.to_string();
if let Err(retry_after) = rate_limiter.check(&client_ip, body.address.trim()) {
return HttpResponse::TooManyRequests()
.insert_header(("Retry-After", retry_after.to_string()))
.json(serde_json::json!({
"error": "faucet cooldown active, try again later",
"retry_after_secs": retry_after
}));
}
match execute_faucet(
client.get_ref(),
config.faucet_private_key.trim(),
Expand All @@ -69,6 +87,8 @@ pub async fn run_graphql_server(
config: AppConfig,
) -> std::io::Result<()> {
let schema = build_schema(ws_manager.clone(), config.clone());
// Shared once across all workers (per-worker Data::new would multiply the limit by worker count).
let rate_limiter = web::Data::new(FaucetRateLimiter::new());
HttpServer::new(move || {
App::new()
.wrap(
Expand All @@ -80,6 +100,7 @@ pub async fn run_graphql_server(
.app_data(web::Data::new(config.clone()))
.app_data(web::Data::new(schema.clone()))
.app_data(web::Data::new(ws_manager.clone()))
.app_data(rate_limiter.clone())
.service(web::resource("/health").route(web::get().to(health_check)))
.service(web::resource("/faucet").route(web::post().to(post_faucet)))
.service(web::resource("/graphql").route(web::post().to(graphql_handler)))
Expand Down
Loading