diff --git a/Cargo.lock b/Cargo.lock index 39d6011..5b8dd78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,10 +75,12 @@ dependencies = [ "aws-sdk-s3", "axum", "clap", + "dashmap", "futures", "include_dir", "mime_guess", "nu-ansi-term", + "rand", "reqwest", "serde", "serde_json", @@ -814,6 +816,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-bigint" version = "0.4.9" @@ -846,6 +854,20 @@ dependencies = [ "typenum", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "der" version = "0.6.1" @@ -1180,6 +1202,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" diff --git a/Cargo.toml b/Cargo.toml index 55a6f1d..e95c262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,10 +8,12 @@ aws-config = "1.8.12" aws-sdk-s3 = "1.119.0" axum = "0.8.8" clap = { version = "4.5.54", features = ["derive", "env"] } +dashmap = "6.1.0" futures = "0.3.31" include_dir = "0.7.4" mime_guess = "2.0.5" nu-ansi-term = "0.50.3" +rand = "0.9.2" reqwest = { version = "0.13.1", default-features = false, features = ["rustls", "charset", "json", "stream"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.148" diff --git a/src/assets.rs b/src/assets.rs index 447305c..edd08ea 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -11,38 +11,35 @@ pub async fn serve_embedded_asset(uri: Uri) -> Response { let asset_path = path.strip_prefix('/').unwrap_or(path); - match CLIENT_ASSETS.get_file(asset_path) { - Some(file) => { - let mime_type = mime_guess::from_path(asset_path) - .first_or_octet_stream() - .as_ref() - .to_string(); + if let Some(file) = CLIENT_ASSETS.get_file(asset_path) { + let mime_type = mime_guess::from_path(asset_path) + .first_or_octet_stream() + .as_ref() + .to_string(); - let mut headers = axum::http::HeaderMap::new(); + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + mime_type + .parse() + .unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream")), + ); + + if path.contains("/immutable/") { headers.insert( - header::CONTENT_TYPE, - mime_type.parse().unwrap_or_else(|_| { - header::HeaderValue::from_static("application/octet-stream") - }), + header::CACHE_CONTROL, + header::HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + } else { + headers.insert( + header::CACHE_CONTROL, + header::HeaderValue::from_static("public, max-age=3600"), ); - - if path.contains("/immutable/") { - headers.insert( - header::CACHE_CONTROL, - header::HeaderValue::from_static("public, max-age=31536000, immutable"), - ); - } else { - headers.insert( - header::CACHE_CONTROL, - header::HeaderValue::from_static("public, max-age=3600"), - ); - } - - (StatusCode::OK, headers, file.contents()).into_response() - } - None => { - tracing::debug!(path, "Embedded asset not found"); - (StatusCode::NOT_FOUND, "Asset not found").into_response() } + + (StatusCode::OK, headers, file.contents()).into_response() + } else { + tracing::debug!(path, "Embedded asset not found"); + (StatusCode::NOT_FOUND, "Asset not found").into_response() } } diff --git a/src/config.rs b/src/config.rs index cb9bcf1..bde2d04 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,7 +34,7 @@ impl FromStr for ListenAddr { if let Some(port_str) = s.strip_prefix(':') { let port: u16 = port_str .parse() - .map_err(|_| format!("Invalid port number: {}", port_str))?; + .map_err(|_| format!("Invalid port number: {port_str}"))?; return Ok(ListenAddr::Tcp(SocketAddr::from(([127, 0, 0, 1], port)))); } @@ -43,11 +43,10 @@ impl FromStr for ListenAddr { Err(_) => match s.to_socket_addrs() { Ok(mut addrs) => addrs .next() - .ok_or_else(|| format!("Could not resolve address: {}", s)) + .ok_or_else(|| format!("Could not resolve address: {s}")) .map(ListenAddr::Tcp), Err(_) => Err(format!( - "Invalid address '{}'. Expected host:port, :port, or Unix socket path", - s + "Invalid address '{s}'. Expected host:port, :port, or Unix socket path" )), }, } @@ -57,7 +56,7 @@ impl FromStr for ListenAddr { impl std::fmt::Display for ListenAddr { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - ListenAddr::Tcp(addr) => write!(f, "{}", addr), + ListenAddr::Tcp(addr) => write!(f, "{addr}"), ListenAddr::Unix(path) => write!(f, "{}", path.display()), } } diff --git a/src/formatter.rs b/src/formatter.rs index 7919c63..56b93c0 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -30,7 +30,7 @@ where let now = OffsetDateTime::now_utc(); let formatted_time = now.format(&TIMESTAMP_FORMAT).map_err(|e| { - eprintln!("Failed to format timestamp: {}", e); + eprintln!("Failed to format timestamp: {e}"); fmt::Error })?; write_dimmed(&mut writer, formatted_time)?; @@ -48,12 +48,12 @@ where write_dimmed(&mut writer, ":")?; let ext = span.extensions(); - if let Some(fields) = &ext.get::>() { - if !fields.fields.is_empty() { - write_bold(&mut writer, "{")?; - writer.write_str(fields.fields.as_str())?; - write_bold(&mut writer, "}")?; - } + if let Some(fields) = &ext.get::>() + && !fields.fields.is_empty() + { + write_bold(&mut writer, "{")?; + writer.write_str(fields.fields.as_str())?; + write_bold(&mut writer, "}")?; } write_dimmed(&mut writer, ":")?; } @@ -109,14 +109,14 @@ where fields: &'a mut Map, } - impl<'a> Visit for FieldVisitor<'a> { + impl Visit for FieldVisitor<'_> { fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { let key = field.name(); if key == "message" { - *self.message = Some(format!("{:?}", value)); + *self.message = Some(format!("{value:?}")); } else { self.fields - .insert(key.to_string(), Value::String(format!("{:?}", value))); + .insert(key.to_string(), Value::String(format!("{value:?}"))); } } @@ -210,7 +210,7 @@ fn write_colored_level(writer: &mut Writer<'_>, level: &Level) -> fmt::Result { Level::WARN => Color::Yellow.paint(" WARN"), Level::ERROR => Color::Red.paint("ERROR"), }; - write!(writer, "{}", colored) + write!(writer, "{colored}") } else { match *level { Level::TRACE => write!(writer, "{:>5}", "TRACE"), @@ -226,7 +226,7 @@ fn write_dimmed(writer: &mut Writer<'_>, s: impl fmt::Display) -> fmt::Result { if writer.has_ansi_escapes() { write!(writer, "{}", Color::DarkGray.paint(s.to_string())) } else { - write!(writer, "{}", s) + write!(writer, "{s}") } } @@ -234,6 +234,6 @@ fn write_bold(writer: &mut Writer<'_>, s: impl fmt::Display) -> fmt::Result { if writer.has_ansi_escapes() { write!(writer, "{}", Color::White.bold().paint(s.to_string())) } else { - write!(writer, "{}", s) + write!(writer, "{s}") } } diff --git a/src/main.rs b/src/main.rs index 2139a2a..15b46fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,13 @@ use axum::{ Json, Router, - extract::{Request, State}, + extract::{ConnectInfo, Request, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::any, }; use clap::Parser; use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -20,11 +21,13 @@ mod health; mod middleware; mod og; mod r2; +mod tarpit; use assets::serve_embedded_asset; use config::{Args, ListenAddr}; use formatter::{CustomJsonFormatter, CustomPrettyFormatter}; use health::HealthChecker; use middleware::RequestIdLayer; +use tarpit::{TarpitConfig, TarpitState, is_malicious_path, tarpit_handler}; fn init_tracing() { let use_json = std::env::var("LOG_JSON") @@ -42,7 +45,7 @@ fn init_tracing() { } }); - EnvFilter::new(format!("warn,api={}", our_level)) + EnvFilter::new(format!("warn,api={our_level}")) }; if use_json { @@ -114,11 +117,26 @@ async fn main() { async move { perform_health_check(downstream_url, http_client, unix_client).await } })); + let tarpit_config = TarpitConfig::from_env(); + let tarpit_state = Arc::new(TarpitState::new(tarpit_config)); + + tracing::info!( + enabled = tarpit_state.config.enabled, + delay_range_ms = format!( + "{}-{}", + tarpit_state.config.delay_min_ms, tarpit_state.config.delay_max_ms + ), + max_global = tarpit_state.config.max_global_connections, + max_per_ip = tarpit_state.config.max_connections_per_ip, + "Tarpit initialized" + ); + let state = Arc::new(AppState { downstream_url: args.downstream.clone(), http_client, unix_client, health_checker, + tarpit_state, }); // Regenerate common OGP images on startup @@ -129,29 +147,44 @@ async fn main() { } }); - let app = Router::new() - .nest("/api", api_routes()) - .route("/api/", any(api_root_404_handler)) - .route( - "/_app/{*path}", - axum::routing::get(serve_embedded_asset).head(serve_embedded_asset), - ) - .fallback(isr_handler) - .layer(TraceLayer::new_for_http()) - .layer(RequestIdLayer::new(args.trust_request_id.clone())) - .layer(CorsLayer::permissive()) - .layer(RequestBodyLimitLayer::new(1_048_576)) - .with_state(state); + // Build base router (shared routes) + fn build_base_router() -> Router> { + Router::new() + .nest("/api", api_routes()) + .route("/api/", any(api_root_404_handler)) + .route( + "/_app/{*path}", + axum::routing::get(serve_embedded_asset).head(serve_embedded_asset), + ) + } + + fn apply_middleware( + router: Router>, + trust_request_id: Option, + ) -> Router> { + router + .layer(TraceLayer::new_for_http()) + .layer(RequestIdLayer::new(trust_request_id)) + .layer(CorsLayer::permissive()) + .layer(RequestBodyLimitLayer::new(1_048_576)) + } let mut tasks = Vec::new(); for listen_addr in &args.listen { - let app = app.clone(); + let state = state.clone(); + let trust_request_id = args.trust_request_id.clone(); let listen_addr = listen_addr.clone(); let task = tokio::spawn(async move { match listen_addr { ListenAddr::Tcp(addr) => { + let app = apply_middleware( + build_base_router().fallback(fallback_handler_tcp), + trust_request_id, + ) + .with_state(state); + let listener = tokio::net::TcpListener::bind(addr) .await .expect("Failed to bind TCP listener"); @@ -163,11 +196,20 @@ async fn main() { }; tracing::info!(url, "Listening on TCP"); - axum::serve(listener, app) - .await - .expect("Server error on TCP listener"); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .expect("Server error on TCP listener"); } ListenAddr::Unix(path) => { + let app = apply_middleware( + build_base_router().fallback(fallback_handler_unix), + trust_request_id, + ) + .with_state(state); + let _ = std::fs::remove_file(&path); let listener = tokio::net::UnixListener::bind(&path) @@ -195,6 +237,7 @@ pub struct AppState { http_client: reqwest::Client, unix_client: Option, health_checker: Arc, + tarpit_state: Arc, } #[derive(Debug)] @@ -206,8 +249,8 @@ pub enum ProxyError { impl std::fmt::Display for ProxyError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - ProxyError::Network(e) => write!(f, "Network error: {}", e), - ProxyError::Other(s) => write!(f, "{}", s), + ProxyError::Network(e) => write!(f, "Network error: {e}"), + ProxyError::Other(s) => write!(f, "{s}"), } } } @@ -380,6 +423,39 @@ async fn projects_handler() -> impl IntoResponse { Json(projects) } +fn should_tarpit(state: &TarpitState, path: &str) -> bool { + state.config.enabled && is_malicious_path(path) +} + +async fn fallback_handler_tcp( + State(state): State>, + ConnectInfo(peer): ConnectInfo, + req: Request, +) -> Response { + let path = req.uri().path(); + + if should_tarpit(&state.tarpit_state, path) { + tarpit_handler( + State(state.tarpit_state.clone()), + Some(ConnectInfo(peer)), + req, + ) + .await + } else { + isr_handler(State(state), req).await + } +} + +async fn fallback_handler_unix(State(state): State>, req: Request) -> Response { + let path = req.uri().path(); + + if should_tarpit(&state.tarpit_state, path) { + tarpit_handler(State(state.tarpit_state.clone()), None, req).await + } else { + isr_handler(State(state), req).await + } +} + #[tracing::instrument(skip(state, req), fields(path = %req.uri().path(), method = %req.method()))] async fn isr_handler(State(state): State>, req: Request) -> Response { let method = req.method().clone(); @@ -418,16 +494,14 @@ async fn isr_handler(State(state): State>, req: Request) -> Respon let bun_url = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./") { if query.is_empty() { - format!("http://localhost{}", path) + format!("http://localhost{path}") } else { - format!("http://localhost{}?{}", path, query) + format!("http://localhost{path}?{query}") } + } else if query.is_empty() { + format!("{}{}", state.downstream_url, path) } else { - if query.is_empty() { - format!("{}{}", state.downstream_url, path) - } else { - format!("{}{}?{}", state.downstream_url, path, query) - } + format!("{}{}?{}", state.downstream_url, path, query) }; let start = std::time::Instant::now(); @@ -493,7 +567,7 @@ async fn isr_handler(State(state): State>, req: Request) -> Respon ); ( StatusCode::BAD_GATEWAY, - format!("Failed to render page: {}", err), + format!("Failed to render page: {err}"), ) .into_response() } @@ -525,10 +599,10 @@ async fn proxy_to_bun( continue; } - if let Ok(header_name) = axum::http::HeaderName::try_from(name.as_str()) { - if let Ok(header_value) = axum::http::HeaderValue::try_from(value.as_bytes()) { - headers.insert(header_name, header_value); - } + if let Ok(header_name) = axum::http::HeaderName::try_from(name.as_str()) + && let Ok(header_value) = axum::http::HeaderValue::try_from(value.as_bytes()) + { + headers.insert(header_name, header_value); } } @@ -544,7 +618,7 @@ async fn perform_health_check( let url = if downstream_url.starts_with('/') || downstream_url.starts_with("./") { "http://localhost/internal/health".to_string() } else { - format!("{}/internal/health", downstream_url) + format!("{downstream_url}/internal/health") }; let client = if unix_client.is_some() { diff --git a/src/middleware.rs b/src/middleware.rs index 190379f..1fb0468 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -53,8 +53,10 @@ where .as_ref() .and_then(|header| req.headers().get(header)) .and_then(|value| value.to_str().ok()) - .map(|s| s.to_string()) - .unwrap_or_else(|| ulid::Ulid::new().to_string()); + .map_or_else( + || ulid::Ulid::new().to_string(), + std::string::ToString::to_string, + ); let span = tracing::info_span!("request", req_id = %req_id); let _enter = span.enter(); diff --git a/src/og.rs b/src/og.rs index b018855..bb4bce2 100644 --- a/src/og.rs +++ b/src/og.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration}; use crate::{AppState, r2::R2Client}; -/// Discriminated union matching TypeScript's OGImageSpec in web/src/lib/og-types.ts +/// Discriminated union matching TypeScript's `OGImageSpec` in web/src/lib/og-types.ts /// /// IMPORTANT: Keep this in sync with the TypeScript definition. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -20,7 +20,7 @@ impl OGImageSpec { match self { OGImageSpec::Index => "og/index.png".to_string(), OGImageSpec::Projects => "og/projects.png".to_string(), - OGImageSpec::Project { id } => format!("og/project/{}.png", id), + OGImageSpec::Project { id } => format!("og/project/{id}.png"), } } } @@ -51,29 +51,30 @@ pub async fn generate_og_image(spec: &OGImageSpec, state: Arc) -> Resu .timeout(Duration::from_secs(30)) .send() .await - .map_err(|e| format!("Failed to call Bun: {}", e))?; + .map_err(|e| format!("Failed to call Bun: {e}"))?; if !response.status().is_success() { let status = response.status(); let error_text = response.text().await.unwrap_or_default(); - return Err(format!("Bun returned status {}: {}", status, error_text)); + return Err(format!("Bun returned status {status}: {error_text}")); } let bytes = response .bytes() .await - .map_err(|e| format!("Failed to read response: {}", e))? + .map_err(|e| format!("Failed to read response: {e}"))? .to_vec(); r2.put_object(&r2_key, bytes) .await - .map_err(|e| format!("Failed to upload to R2: {}", e))?; + .map_err(|e| format!("Failed to upload to R2: {e}"))?; tracing::info!(r2_key, "OG image generated and uploaded"); Ok(()) } /// Check if an OG image exists in R2 +#[allow(dead_code)] pub async fn og_image_exists(spec: &OGImageSpec) -> bool { if let Some(r2) = R2Client::get().await { r2.object_exists(&spec.r2_key()).await @@ -83,6 +84,7 @@ pub async fn og_image_exists(spec: &OGImageSpec) -> bool { } /// Ensure an OG image exists, generating if necessary +#[allow(dead_code)] pub async fn ensure_og_image(spec: &OGImageSpec, state: Arc) -> Result<(), String> { if og_image_exists(spec).await { tracing::debug!(r2_key = spec.r2_key(), "OG image already exists"); diff --git a/src/r2.rs b/src/r2.rs index 5db0c66..df4e8a9 100644 --- a/src/r2.rs +++ b/src/r2.rs @@ -24,7 +24,7 @@ impl R2Client { .map_err(|_| "R2_SECRET_ACCESS_KEY not set".to_string())?; let bucket = std::env::var("R2_BUCKET").map_err(|_| "R2_BUCKET not set".to_string())?; - let endpoint = format!("https://{}.r2.cloudflarestorage.com", account_id); + let endpoint = format!("https://{account_id}.r2.cloudflarestorage.com"); let credentials_provider = Credentials::new(access_key_id, secret_access_key, None, None, "static"); @@ -57,6 +57,7 @@ impl R2Client { .cloned() } + #[allow(dead_code)] pub async fn get_object(&self, key: &str) -> Result, String> { let result = self .client @@ -65,13 +66,13 @@ impl R2Client { .key(key) .send() .await - .map_err(|e| format!("Failed to get object from R2: {}", e))?; + .map_err(|e| format!("Failed to get object from R2: {e}"))?; let bytes = result .body .collect() .await - .map_err(|e| format!("Failed to read object body: {}", e))? + .map_err(|e| format!("Failed to read object body: {e}"))? .into_bytes() .to_vec(); @@ -87,11 +88,12 @@ impl R2Client { .content_type("image/png") .send() .await - .map_err(|e| format!("Failed to put object to R2: {}", e))?; + .map_err(|e| format!("Failed to put object to R2: {e}"))?; Ok(()) } + #[allow(dead_code)] pub async fn object_exists(&self, key: &str) -> bool { self.client .head_object() diff --git a/src/tarpit.rs b/src/tarpit.rs new file mode 100644 index 0000000..0762769 --- /dev/null +++ b/src/tarpit.rs @@ -0,0 +1,549 @@ +use axum::{ + body::Body, + extract::{ConnectInfo, Request, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use dashmap::DashMap; +use futures::stream::{self, Stream}; +use rand::Rng; +use std::net::{IpAddr, SocketAddr}; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Semaphore; +use tokio::time::Instant; + +#[derive(Debug, Clone)] +pub struct TarpitConfig { + pub enabled: bool, + pub delay_min_ms: u64, + pub delay_max_ms: u64, + pub chunk_size_min: usize, + pub chunk_size_max: usize, + pub max_global_connections: usize, + pub max_connections_per_ip: usize, +} + +impl TarpitConfig { + pub fn from_env() -> Self { + Self { + enabled: std::env::var("TARPIT_ENABLED") + .map(|v| v == "true" || v == "1") + .unwrap_or(true), + delay_min_ms: std::env::var("TARPIT_DELAY_MIN_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(100), + delay_max_ms: std::env::var("TARPIT_DELAY_MAX_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500), + chunk_size_min: std::env::var("TARPIT_CHUNK_MIN") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(64), + chunk_size_max: std::env::var("TARPIT_CHUNK_MAX") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1024), + max_global_connections: std::env::var("TARPIT_MAX_GLOBAL") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1000), + max_connections_per_ip: std::env::var("TARPIT_MAX_PER_IP") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(100), + } + } +} + +pub struct TarpitState { + global_semaphore: Arc, + ip_connections: Arc>>, + pub config: Arc, +} + +impl TarpitState { + pub fn new(config: TarpitConfig) -> Self { + let config = Arc::new(config); + Self { + global_semaphore: Arc::new(Semaphore::new(config.max_global_connections)), + ip_connections: Arc::new(DashMap::new()), + config, + } + } +} + +#[derive(Debug, Clone, Copy)] +enum ResponseMode { + RandomBytes, + FakeHtml, + FakeJson, +} + +impl ResponseMode { + fn random() -> Self { + let mut rng = rand::rng(); + match rng.random_range(0..3) { + 0 => Self::RandomBytes, + 1 => Self::FakeHtml, + _ => Self::FakeJson, + } + } + + fn content_type(&self) -> &'static str { + match self { + Self::RandomBytes => "application/octet-stream", + Self::FakeHtml => "text/html; charset=utf-8", + Self::FakeJson => "application/json", + } + } +} + +pub fn is_malicious_path(path: &str) -> bool { + let path_lower = path.to_lowercase(); + + // File extension checks + if path_lower.ends_with(".php") + || path_lower.ends_with(".asp") + || path_lower.ends_with(".aspx") + || path_lower.ends_with(".sql") + || path_lower.ends_with(".zip") + || path_lower.ends_with(".tar") + || path_lower.ends_with(".tar.gz") + || path_lower.ends_with(".rar") + || path_lower.ends_with(".backup") + { + return true; + } + + // WordPress paths + if path_lower.starts_with("/wp-") || path_lower.starts_with("/wordpress/") { + return true; + } + + // Admin panels + if path_lower.starts_with("/admin") + || path_lower.starts_with("/administrator") + || path_lower.contains("phpmyadmin") + { + return true; + } + + // Config and credential files + if path_lower.starts_with("/.env") + || path_lower.contains("/config.") + || path_lower.contains("/.git/") + || path_lower.contains("/.svn/") + || path_lower.contains("/.hg/") + || path_lower.contains("/.bzr/") + || path_lower.contains("/credentials") + || path_lower.contains("service-account.json") + || path_lower.contains("firebase") + || path_lower.contains("/.aws/") + || path_lower.contains("/.kube/") + { + return true; + } + + // CGI and old web tech + if path_lower.starts_with("/cgi-bin/") { + return true; + } + + // Spring Boot actuators + if path_lower.starts_with("/actuator") { + return true; + } + + // API documentation/explorers + if path_lower.starts_with("/api-docs") + || path_lower.starts_with("/swagger") + || path_lower.starts_with("/graphql") + || path_lower.starts_with("/graphiql") + || path_lower.starts_with("/playground") + { + return true; + } + + // Infrastructure files + if path_lower.contains("/terraform.") + || path_lower.contains("dockerfile") + || path_lower.contains("docker-compose") + || path_lower.contains("/backup") + { + return true; + } + + // Package manager files (except those we might legitimately serve) + if path_lower.contains("composer.json") + || path_lower.contains("composer.lock") + || path_lower.contains("gemfile") + || path_lower.contains("pipfile") + { + return true; + } + + false +} + +pub fn extract_client_ip(headers: &HeaderMap, peer_addr: Option) -> IpAddr { + // Check X-Real-IP first (Railway sets this) + if let Some(real_ip) = headers.get("x-real-ip") + && let Ok(ip_str) = real_ip.to_str() + && let Ok(ip) = ip_str.parse() + { + return ip; + } + + // Fallback to X-Forwarded-For (take first IP) + if let Some(forwarded) = headers.get("x-forwarded-for") + && let Ok(forwarded_str) = forwarded.to_str() + && let Some(first_ip) = forwarded_str.split(',').next() + && let Ok(ip) = first_ip.trim().parse() + { + return ip; + } + + // Fallback to peer address from connection + peer_addr.map_or_else( + || { + tracing::warn!("No peer address available, defaulting to localhost"); + "127.0.0.1".parse().expect("hardcoded IP should parse") + }, + |addr| addr.ip(), + ) +} + +type BoxedByteStream = Pin, std::io::Error>> + Send>>; + +fn create_random_bytes_stream(config: Arc) -> BoxedByteStream { + Box::pin(stream::unfold((), move |()| { + let config = Arc::clone(&config); + async move { + let (delay_ms, chunk) = { + let mut rng = rand::rng(); + let delay_ms = rng.random_range(config.delay_min_ms..=config.delay_max_ms); + let chunk_size = rng.random_range(config.chunk_size_min..=config.chunk_size_max); + let chunk: Vec = (0..chunk_size).map(|_| rng.random()).collect(); + (delay_ms, chunk) + }; + + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + + Some((Ok(chunk), ())) + } + })) +} + +fn create_fake_html_stream(config: Arc) -> BoxedByteStream { + Box::pin(stream::unfold(0, move |counter| { + let config = Arc::clone(&config); + async move { + let (delay_ms, chunk) = { + let mut rng = rand::rng(); + let delay_ms = rng.random_range(config.delay_min_ms..=config.delay_max_ms); + + let chunk = if counter == 0 { + concat!( + "\n", + "\n", + "\n", + " Admin Panel\n", + " \n", + "\n", + "\n", + "

Loading...

\n", + "
\n" + ) + .as_bytes() + .to_vec() + } else { + let elements = [ + "
Processing request...
\n", + " Initializing...\n", + " \n", + "

Fetching records...

\n", + "
\n", + " \n", + ]; + let element = elements[rng.random_range(0..elements.len())]; + element.as_bytes().to_vec() + }; + (delay_ms, chunk) + }; + + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + + Some((Ok(chunk), counter + 1)) + } + })) +} + +fn create_fake_json_stream(config: Arc) -> BoxedByteStream { + Box::pin(stream::unfold(0, move |counter| { + let config = Arc::clone(&config); + async move { + let (delay_ms, chunk) = { + let mut rng = rand::rng(); + let delay_ms = rng.random_range(config.delay_min_ms..=config.delay_max_ms); + + let chunk = if counter == 0 { + b"{\"status\":\"success\",\"data\":[\n".to_vec() + } else { + let id = counter; + let username = format!("user{}", rng.random_range(1000..9999)); + let email = format!("{username}@example.com"); + let json = format!( + "{{\"id\":{id},\"username\":\"{username}\",\"email\":\"{email}\",\"active\":true}},\n" + ); + json.as_bytes().to_vec() + }; + (delay_ms, chunk) + }; + + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + + Some((Ok(chunk), counter + 1)) + } + })) +} +pub async fn tarpit_handler( + State(state): State>, + peer: Option>, + req: Request, +) -> Response { + let path = req.uri().path().to_string(); + let headers = req.headers(); + + let client_ip = extract_client_ip(headers, peer.map(|ConnectInfo(addr)| addr)); + + // Try to acquire global semaphore + let _global_permit = if let Ok(Ok(permit)) = tokio::time::timeout( + Duration::from_millis(100), + state.global_semaphore.clone().acquire_owned(), + ) + .await + { + permit + } else { + tracing::debug!( + client_ip = %client_ip, + reason = "global_limit", + "Tarpit connection rejected" + ); + return (StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable").into_response(); + }; + + // Get or create per-IP semaphore + let ip_semaphore = state + .ip_connections + .entry(client_ip) + .or_insert_with(|| Arc::new(Semaphore::new(state.config.max_connections_per_ip))) + .clone(); + + // Try to acquire per-IP semaphore + let _ip_permit = if let Ok(Ok(permit)) = tokio::time::timeout( + Duration::from_millis(100), + ip_semaphore.clone().acquire_owned(), + ) + .await + { + permit + } else { + tracing::debug!( + client_ip = %client_ip, + reason = "ip_limit", + "Tarpit connection rejected" + ); + return (StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable").into_response(); + }; + + let mode = ResponseMode::random(); + let start = Instant::now(); + + tracing::debug!( + path = %path, + client_ip = %client_ip, + mode = ?mode, + global_available = state.global_semaphore.available_permits(), + ip_available = ip_semaphore.available_permits(), + "Tarpit triggered" + ); + + let stream: BoxedByteStream = match mode { + ResponseMode::RandomBytes => create_random_bytes_stream(Arc::clone(&state.config)), + ResponseMode::FakeHtml => create_fake_html_stream(Arc::clone(&state.config)), + ResponseMode::FakeJson => create_fake_json_stream(Arc::clone(&state.config)), + }; + + // Wrap stream to log on drop and hold permits + let stream_with_logging = stream::unfold( + ( + stream, + start, + client_ip, + 0usize, + false, + _global_permit, + _ip_permit, + ), + |(mut stream, start, client_ip, bytes_sent, logged, global_permit, ip_permit)| async move { + use futures::StreamExt; + + match stream.next().await { + Some(Ok(chunk)) => { + let new_bytes = bytes_sent + chunk.len(); + Some(( + Ok(chunk), + ( + stream, + start, + client_ip, + new_bytes, + logged, + global_permit, + ip_permit, + ), + )) + } + Some(Err(e)) => Some(( + Err(e), + ( + stream, + start, + client_ip, + bytes_sent, + logged, + global_permit, + ip_permit, + ), + )), + None => { + if !logged { + let duration = start.elapsed(); + tracing::debug!( + client_ip = %client_ip, + duration_secs = duration.as_secs(), + bytes_sent, + "Tarpit connection closed" + ); + } + None + } + } + }, + ); + + let body = Body::from_stream(stream_with_logging); + + let mut response = Response::new(body); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert( + axum::http::header::CONTENT_TYPE, + mode.content_type() + .parse() + .expect("content type should be valid header value"), + ); + + response +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_php_files() { + assert!(is_malicious_path("/admin.php")); + assert!(is_malicious_path("/wp-login.php")); + assert!(is_malicious_path("/index.php")); + assert!(is_malicious_path("/INFO.PHP")); + } + + #[test] + fn test_wordpress_paths() { + assert!(is_malicious_path("/wp-admin/")); + assert!(is_malicious_path("/wp-content/plugins/")); + assert!(is_malicious_path("/wp-includes/")); + assert!(is_malicious_path("/wordpress/index.php")); + } + + #[test] + fn test_admin_panels() { + assert!(is_malicious_path("/admin")); + assert!(is_malicious_path("/administrator")); + assert!(is_malicious_path("/phpmyadmin")); + assert!(is_malicious_path("/phpMyAdmin")); + } + + #[test] + fn test_config_files() { + assert!(is_malicious_path("/.env")); + assert!(is_malicious_path("/.git/config")); + assert!(is_malicious_path("/config.php")); + assert!(is_malicious_path("/.aws/credentials")); + } + + #[test] + fn test_actuator_endpoints() { + assert!(is_malicious_path("/actuator")); + assert!(is_malicious_path("/actuator/health")); + } + + #[test] + fn test_api_docs() { + assert!(is_malicious_path("/swagger.json")); + assert!(is_malicious_path("/graphql")); + assert!(is_malicious_path("/api-docs")); + } + + #[test] + fn test_legitimate_paths() { + assert!(!is_malicious_path("/")); + assert!(!is_malicious_path("/about")); + assert!(!is_malicious_path("/api/projects")); + assert!(!is_malicious_path("/favicon.ico")); + assert!(!is_malicious_path("/robots.txt")); + assert!(!is_malicious_path("/sitemap.xml")); + assert!(!is_malicious_path("/keybase.txt")); + assert!(!is_malicious_path("/_app/some-asset.js")); + } + + #[test] + fn test_ip_extraction() { + use std::net::SocketAddr; + let mut headers = HeaderMap::new(); + let peer: SocketAddr = "192.0.2.50:12345".parse().unwrap(); + + // Test X-Real-IP + headers.insert("x-real-ip", "203.0.113.42".parse().unwrap()); + let ip = extract_client_ip(&headers, Some(peer)); + assert_eq!(ip, "203.0.113.42".parse::().unwrap()); + + // Test X-Forwarded-For + headers.clear(); + headers.insert( + "x-forwarded-for", + "198.51.100.1, 192.0.2.1".parse().unwrap(), + ); + let ip = extract_client_ip(&headers, Some(peer)); + assert_eq!(ip, "198.51.100.1".parse::().unwrap()); + + // Test X-Real-IP takes precedence + headers.insert("x-real-ip", "203.0.113.100".parse().unwrap()); + let ip = extract_client_ip(&headers, Some(peer)); + assert_eq!(ip, "203.0.113.100".parse::().unwrap()); + + // Test fallback to peer address + headers.clear(); + let ip = extract_client_ip(&headers, Some(peer)); + assert_eq!(ip, "192.0.2.50".parse::().unwrap()); + + // Test fallback to localhost when no peer + let ip = extract_client_ip(&headers, None); + assert_eq!(ip, "127.0.0.1".parse::().unwrap()); + } +} diff --git a/web/src/lib/components/OgImage.svelte b/web/src/lib/components/OgImage.svelte index 7a6c07f..babdcaf 100644 --- a/web/src/lib/components/OgImage.svelte +++ b/web/src/lib/components/OgImage.svelte @@ -1,52 +1,56 @@
-
- -
-

- {title} -

- {#if subtitle} -

- {subtitle} -

- {/if} -
+
+ +
+

+ {title} +

+ {#if subtitle} +

+ {subtitle} +

+ {/if} +
- -
-
xevion.dev
- {#if type === 'project'} -
- PROJECT -
- {/if} -
-
+ +
+
+ xevion.dev +
+ {#if type === "project"} +
+ PROJECT +
+ {/if} +
+
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 8545394..0b8889e 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -15,7 +15,9 @@ class="max-w-2xl mx-4 border-b border-zinc-700 divide-y divide-zinc-700 sm:mx-6" >
- Ryan Walters, + Ryan Walters, Full-Stack Software Engineer @@ -45,7 +47,8 @@ class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-900 shadow-sm hover:bg-zinc-800 transition-colors" > - LinkedIn + LinkedIn