mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 06:26:44 -06:00
feat: add connection tarpit for malicious bot traffic
Implements slow-drip response handler for known bot paths (wp-admin, phpmyadmin, etc.) to waste attacker resources. Includes per-IP and global connection limits, configurable delays, and random chunking. Also applies clippy lint fixes across codebase.
This commit is contained in:
Generated
+28
@@ -75,10 +75,12 @@ dependencies = [
|
|||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
"axum",
|
"axum",
|
||||||
"clap",
|
"clap",
|
||||||
|
"dashmap",
|
||||||
"futures",
|
"futures",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
|
"rand",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -814,6 +816,12 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-utils"
|
||||||
|
version = "0.8.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-bigint"
|
name = "crypto-bigint"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
@@ -846,6 +854,20 @@ dependencies = [
|
|||||||
"typenum",
|
"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]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -1180,6 +1202,12 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ aws-config = "1.8.12"
|
|||||||
aws-sdk-s3 = "1.119.0"
|
aws-sdk-s3 = "1.119.0"
|
||||||
axum = "0.8.8"
|
axum = "0.8.8"
|
||||||
clap = { version = "4.5.54", features = ["derive", "env"] }
|
clap = { version = "4.5.54", features = ["derive", "env"] }
|
||||||
|
dashmap = "6.1.0"
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
include_dir = "0.7.4"
|
include_dir = "0.7.4"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
nu-ansi-term = "0.50.3"
|
nu-ansi-term = "0.50.3"
|
||||||
|
rand = "0.9.2"
|
||||||
reqwest = { version = "0.13.1", default-features = false, features = ["rustls", "charset", "json", "stream"] }
|
reqwest = { version = "0.13.1", default-features = false, features = ["rustls", "charset", "json", "stream"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.148"
|
serde_json = "1.0.148"
|
||||||
|
|||||||
+5
-8
@@ -11,8 +11,7 @@ pub async fn serve_embedded_asset(uri: Uri) -> Response {
|
|||||||
|
|
||||||
let asset_path = path.strip_prefix('/').unwrap_or(path);
|
let asset_path = path.strip_prefix('/').unwrap_or(path);
|
||||||
|
|
||||||
match CLIENT_ASSETS.get_file(asset_path) {
|
if let Some(file) = CLIENT_ASSETS.get_file(asset_path) {
|
||||||
Some(file) => {
|
|
||||||
let mime_type = mime_guess::from_path(asset_path)
|
let mime_type = mime_guess::from_path(asset_path)
|
||||||
.first_or_octet_stream()
|
.first_or_octet_stream()
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -21,9 +20,9 @@ pub async fn serve_embedded_asset(uri: Uri) -> Response {
|
|||||||
let mut headers = axum::http::HeaderMap::new();
|
let mut headers = axum::http::HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
header::CONTENT_TYPE,
|
header::CONTENT_TYPE,
|
||||||
mime_type.parse().unwrap_or_else(|_| {
|
mime_type
|
||||||
header::HeaderValue::from_static("application/octet-stream")
|
.parse()
|
||||||
}),
|
.unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream")),
|
||||||
);
|
);
|
||||||
|
|
||||||
if path.contains("/immutable/") {
|
if path.contains("/immutable/") {
|
||||||
@@ -39,10 +38,8 @@ pub async fn serve_embedded_asset(uri: Uri) -> Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
(StatusCode::OK, headers, file.contents()).into_response()
|
(StatusCode::OK, headers, file.contents()).into_response()
|
||||||
}
|
} else {
|
||||||
None => {
|
|
||||||
tracing::debug!(path, "Embedded asset not found");
|
tracing::debug!(path, "Embedded asset not found");
|
||||||
(StatusCode::NOT_FOUND, "Asset not found").into_response()
|
(StatusCode::NOT_FOUND, "Asset not found").into_response()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-5
@@ -34,7 +34,7 @@ impl FromStr for ListenAddr {
|
|||||||
if let Some(port_str) = s.strip_prefix(':') {
|
if let Some(port_str) = s.strip_prefix(':') {
|
||||||
let port: u16 = port_str
|
let port: u16 = port_str
|
||||||
.parse()
|
.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))));
|
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() {
|
Err(_) => match s.to_socket_addrs() {
|
||||||
Ok(mut addrs) => addrs
|
Ok(mut addrs) => addrs
|
||||||
.next()
|
.next()
|
||||||
.ok_or_else(|| format!("Could not resolve address: {}", s))
|
.ok_or_else(|| format!("Could not resolve address: {s}"))
|
||||||
.map(ListenAddr::Tcp),
|
.map(ListenAddr::Tcp),
|
||||||
Err(_) => Err(format!(
|
Err(_) => Err(format!(
|
||||||
"Invalid address '{}'. Expected host:port, :port, or Unix socket path",
|
"Invalid address '{s}'. Expected host:port, :port, or Unix socket path"
|
||||||
s
|
|
||||||
)),
|
)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -57,7 +56,7 @@ impl FromStr for ListenAddr {
|
|||||||
impl std::fmt::Display for ListenAddr {
|
impl std::fmt::Display for ListenAddr {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
ListenAddr::Tcp(addr) => write!(f, "{}", addr),
|
ListenAddr::Tcp(addr) => write!(f, "{addr}"),
|
||||||
ListenAddr::Unix(path) => write!(f, "{}", path.display()),
|
ListenAddr::Unix(path) => write!(f, "{}", path.display()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -30,7 +30,7 @@ where
|
|||||||
|
|
||||||
let now = OffsetDateTime::now_utc();
|
let now = OffsetDateTime::now_utc();
|
||||||
let formatted_time = now.format(&TIMESTAMP_FORMAT).map_err(|e| {
|
let formatted_time = now.format(&TIMESTAMP_FORMAT).map_err(|e| {
|
||||||
eprintln!("Failed to format timestamp: {}", e);
|
eprintln!("Failed to format timestamp: {e}");
|
||||||
fmt::Error
|
fmt::Error
|
||||||
})?;
|
})?;
|
||||||
write_dimmed(&mut writer, formatted_time)?;
|
write_dimmed(&mut writer, formatted_time)?;
|
||||||
@@ -48,13 +48,13 @@ where
|
|||||||
write_dimmed(&mut writer, ":")?;
|
write_dimmed(&mut writer, ":")?;
|
||||||
|
|
||||||
let ext = span.extensions();
|
let ext = span.extensions();
|
||||||
if let Some(fields) = &ext.get::<FormattedFields<N>>() {
|
if let Some(fields) = &ext.get::<FormattedFields<N>>()
|
||||||
if !fields.fields.is_empty() {
|
&& !fields.fields.is_empty()
|
||||||
|
{
|
||||||
write_bold(&mut writer, "{")?;
|
write_bold(&mut writer, "{")?;
|
||||||
writer.write_str(fields.fields.as_str())?;
|
writer.write_str(fields.fields.as_str())?;
|
||||||
write_bold(&mut writer, "}")?;
|
write_bold(&mut writer, "}")?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
write_dimmed(&mut writer, ":")?;
|
write_dimmed(&mut writer, ":")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,14 +109,14 @@ where
|
|||||||
fields: &'a mut Map<String, Value>,
|
fields: &'a mut Map<String, Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Visit for FieldVisitor<'a> {
|
impl Visit for FieldVisitor<'_> {
|
||||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||||
let key = field.name();
|
let key = field.name();
|
||||||
if key == "message" {
|
if key == "message" {
|
||||||
*self.message = Some(format!("{:?}", value));
|
*self.message = Some(format!("{value:?}"));
|
||||||
} else {
|
} else {
|
||||||
self.fields
|
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::WARN => Color::Yellow.paint(" WARN"),
|
||||||
Level::ERROR => Color::Red.paint("ERROR"),
|
Level::ERROR => Color::Red.paint("ERROR"),
|
||||||
};
|
};
|
||||||
write!(writer, "{}", colored)
|
write!(writer, "{colored}")
|
||||||
} else {
|
} else {
|
||||||
match *level {
|
match *level {
|
||||||
Level::TRACE => write!(writer, "{:>5}", "TRACE"),
|
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() {
|
if writer.has_ansi_escapes() {
|
||||||
write!(writer, "{}", Color::DarkGray.paint(s.to_string()))
|
write!(writer, "{}", Color::DarkGray.paint(s.to_string()))
|
||||||
} else {
|
} 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() {
|
if writer.has_ansi_escapes() {
|
||||||
write!(writer, "{}", Color::White.bold().paint(s.to_string()))
|
write!(writer, "{}", Color::White.bold().paint(s.to_string()))
|
||||||
} else {
|
} else {
|
||||||
write!(writer, "{}", s)
|
write!(writer, "{s}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-20
@@ -1,12 +1,13 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Request, State},
|
extract::{ConnectInfo, Request, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::any,
|
routing::any,
|
||||||
};
|
};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -20,11 +21,13 @@ mod health;
|
|||||||
mod middleware;
|
mod middleware;
|
||||||
mod og;
|
mod og;
|
||||||
mod r2;
|
mod r2;
|
||||||
|
mod tarpit;
|
||||||
use assets::serve_embedded_asset;
|
use assets::serve_embedded_asset;
|
||||||
use config::{Args, ListenAddr};
|
use config::{Args, ListenAddr};
|
||||||
use formatter::{CustomJsonFormatter, CustomPrettyFormatter};
|
use formatter::{CustomJsonFormatter, CustomPrettyFormatter};
|
||||||
use health::HealthChecker;
|
use health::HealthChecker;
|
||||||
use middleware::RequestIdLayer;
|
use middleware::RequestIdLayer;
|
||||||
|
use tarpit::{TarpitConfig, TarpitState, is_malicious_path, tarpit_handler};
|
||||||
|
|
||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
let use_json = std::env::var("LOG_JSON")
|
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 {
|
if use_json {
|
||||||
@@ -114,11 +117,26 @@ async fn main() {
|
|||||||
async move { perform_health_check(downstream_url, http_client, unix_client).await }
|
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 {
|
let state = Arc::new(AppState {
|
||||||
downstream_url: args.downstream.clone(),
|
downstream_url: args.downstream.clone(),
|
||||||
http_client,
|
http_client,
|
||||||
unix_client,
|
unix_client,
|
||||||
health_checker,
|
health_checker,
|
||||||
|
tarpit_state,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regenerate common OGP images on startup
|
// Regenerate common OGP images on startup
|
||||||
@@ -129,29 +147,44 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
// Build base router (shared routes)
|
||||||
|
fn build_base_router() -> Router<Arc<AppState>> {
|
||||||
|
Router::new()
|
||||||
.nest("/api", api_routes())
|
.nest("/api", api_routes())
|
||||||
.route("/api/", any(api_root_404_handler))
|
.route("/api/", any(api_root_404_handler))
|
||||||
.route(
|
.route(
|
||||||
"/_app/{*path}",
|
"/_app/{*path}",
|
||||||
axum::routing::get(serve_embedded_asset).head(serve_embedded_asset),
|
axum::routing::get(serve_embedded_asset).head(serve_embedded_asset),
|
||||||
)
|
)
|
||||||
.fallback(isr_handler)
|
}
|
||||||
|
|
||||||
|
fn apply_middleware(
|
||||||
|
router: Router<Arc<AppState>>,
|
||||||
|
trust_request_id: Option<String>,
|
||||||
|
) -> Router<Arc<AppState>> {
|
||||||
|
router
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(RequestIdLayer::new(args.trust_request_id.clone()))
|
.layer(RequestIdLayer::new(trust_request_id))
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.layer(RequestBodyLimitLayer::new(1_048_576))
|
.layer(RequestBodyLimitLayer::new(1_048_576))
|
||||||
.with_state(state);
|
}
|
||||||
|
|
||||||
let mut tasks = Vec::new();
|
let mut tasks = Vec::new();
|
||||||
|
|
||||||
for listen_addr in &args.listen {
|
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 listen_addr = listen_addr.clone();
|
||||||
|
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
match listen_addr {
|
match listen_addr {
|
||||||
ListenAddr::Tcp(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)
|
let listener = tokio::net::TcpListener::bind(addr)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to bind TCP listener");
|
.expect("Failed to bind TCP listener");
|
||||||
@@ -163,11 +196,20 @@ async fn main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
tracing::info!(url, "Listening on TCP");
|
tracing::info!(url, "Listening on TCP");
|
||||||
axum::serve(listener, app)
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Server error on TCP listener");
|
.expect("Server error on TCP listener");
|
||||||
}
|
}
|
||||||
ListenAddr::Unix(path) => {
|
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 _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
let listener = tokio::net::UnixListener::bind(&path)
|
let listener = tokio::net::UnixListener::bind(&path)
|
||||||
@@ -195,6 +237,7 @@ pub struct AppState {
|
|||||||
http_client: reqwest::Client,
|
http_client: reqwest::Client,
|
||||||
unix_client: Option<reqwest::Client>,
|
unix_client: Option<reqwest::Client>,
|
||||||
health_checker: Arc<HealthChecker>,
|
health_checker: Arc<HealthChecker>,
|
||||||
|
tarpit_state: Arc<TarpitState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -206,8 +249,8 @@ pub enum ProxyError {
|
|||||||
impl std::fmt::Display for ProxyError {
|
impl std::fmt::Display for ProxyError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
ProxyError::Network(e) => write!(f, "Network error: {}", e),
|
ProxyError::Network(e) => write!(f, "Network error: {e}"),
|
||||||
ProxyError::Other(s) => write!(f, "{}", s),
|
ProxyError::Other(s) => write!(f, "{s}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,6 +423,39 @@ async fn projects_handler() -> impl IntoResponse {
|
|||||||
Json(projects)
|
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<Arc<AppState>>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
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<Arc<AppState>>, 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()))]
|
#[tracing::instrument(skip(state, req), fields(path = %req.uri().path(), method = %req.method()))]
|
||||||
async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Response {
|
async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Response {
|
||||||
let method = req.method().clone();
|
let method = req.method().clone();
|
||||||
@@ -418,16 +494,14 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
|
|||||||
let bun_url = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./")
|
let bun_url = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./")
|
||||||
{
|
{
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
format!("http://localhost{}", path)
|
format!("http://localhost{path}")
|
||||||
} else {
|
} else {
|
||||||
format!("http://localhost{}?{}", path, query)
|
format!("http://localhost{path}?{query}")
|
||||||
}
|
}
|
||||||
} else {
|
} else if query.is_empty() {
|
||||||
if query.is_empty() {
|
|
||||||
format!("{}{}", state.downstream_url, path)
|
format!("{}{}", state.downstream_url, path)
|
||||||
} else {
|
} else {
|
||||||
format!("{}{}?{}", state.downstream_url, path, query)
|
format!("{}{}?{}", state.downstream_url, path, query)
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
@@ -493,7 +567,7 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
|
|||||||
);
|
);
|
||||||
(
|
(
|
||||||
StatusCode::BAD_GATEWAY,
|
StatusCode::BAD_GATEWAY,
|
||||||
format!("Failed to render page: {}", err),
|
format!("Failed to render page: {err}"),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -525,12 +599,12 @@ async fn proxy_to_bun(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(header_name) = axum::http::HeaderName::try_from(name.as_str()) {
|
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()) {
|
&& let Ok(header_value) = axum::http::HeaderValue::try_from(value.as_bytes())
|
||||||
|
{
|
||||||
headers.insert(header_name, header_value);
|
headers.insert(header_name, header_value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let body = response.bytes().await.map_err(ProxyError::Network)?;
|
let body = response.bytes().await.map_err(ProxyError::Network)?;
|
||||||
Ok((status, headers, body))
|
Ok((status, headers, body))
|
||||||
@@ -544,7 +618,7 @@ async fn perform_health_check(
|
|||||||
let url = if downstream_url.starts_with('/') || downstream_url.starts_with("./") {
|
let url = if downstream_url.starts_with('/') || downstream_url.starts_with("./") {
|
||||||
"http://localhost/internal/health".to_string()
|
"http://localhost/internal/health".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("{}/internal/health", downstream_url)
|
format!("{downstream_url}/internal/health")
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = if unix_client.is_some() {
|
let client = if unix_client.is_some() {
|
||||||
|
|||||||
+4
-2
@@ -53,8 +53,10 @@ where
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|header| req.headers().get(header))
|
.and_then(|header| req.headers().get(header))
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.map(|s| s.to_string())
|
.map_or_else(
|
||||||
.unwrap_or_else(|| ulid::Ulid::new().to_string());
|
|| ulid::Ulid::new().to_string(),
|
||||||
|
std::string::ToString::to_string,
|
||||||
|
);
|
||||||
|
|
||||||
let span = tracing::info_span!("request", req_id = %req_id);
|
let span = tracing::info_span!("request", req_id = %req_id);
|
||||||
let _enter = span.enter();
|
let _enter = span.enter();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration};
|
|||||||
|
|
||||||
use crate::{AppState, r2::R2Client};
|
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.
|
/// IMPORTANT: Keep this in sync with the TypeScript definition.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -20,7 +20,7 @@ impl OGImageSpec {
|
|||||||
match self {
|
match self {
|
||||||
OGImageSpec::Index => "og/index.png".to_string(),
|
OGImageSpec::Index => "og/index.png".to_string(),
|
||||||
OGImageSpec::Projects => "og/projects.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<AppState>) -> Resu
|
|||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to call Bun: {}", e))?;
|
.map_err(|e| format!("Failed to call Bun: {e}"))?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let error_text = response.text().await.unwrap_or_default();
|
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
|
let bytes = response
|
||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to read response: {}", e))?
|
.map_err(|e| format!("Failed to read response: {e}"))?
|
||||||
.to_vec();
|
.to_vec();
|
||||||
|
|
||||||
r2.put_object(&r2_key, bytes)
|
r2.put_object(&r2_key, bytes)
|
||||||
.await
|
.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");
|
tracing::info!(r2_key, "OG image generated and uploaded");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an OG image exists in R2
|
/// Check if an OG image exists in R2
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn og_image_exists(spec: &OGImageSpec) -> bool {
|
pub async fn og_image_exists(spec: &OGImageSpec) -> bool {
|
||||||
if let Some(r2) = R2Client::get().await {
|
if let Some(r2) = R2Client::get().await {
|
||||||
r2.object_exists(&spec.r2_key()).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
|
/// Ensure an OG image exists, generating if necessary
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn ensure_og_image(spec: &OGImageSpec, state: Arc<AppState>) -> Result<(), String> {
|
pub async fn ensure_og_image(spec: &OGImageSpec, state: Arc<AppState>) -> Result<(), String> {
|
||||||
if og_image_exists(spec).await {
|
if og_image_exists(spec).await {
|
||||||
tracing::debug!(r2_key = spec.r2_key(), "OG image already exists");
|
tracing::debug!(r2_key = spec.r2_key(), "OG image already exists");
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ impl R2Client {
|
|||||||
.map_err(|_| "R2_SECRET_ACCESS_KEY not set".to_string())?;
|
.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 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 =
|
let credentials_provider =
|
||||||
Credentials::new(access_key_id, secret_access_key, None, None, "static");
|
Credentials::new(access_key_id, secret_access_key, None, None, "static");
|
||||||
@@ -57,6 +57,7 @@ impl R2Client {
|
|||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>, String> {
|
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>, String> {
|
||||||
let result = self
|
let result = self
|
||||||
.client
|
.client
|
||||||
@@ -65,13 +66,13 @@ impl R2Client {
|
|||||||
.key(key)
|
.key(key)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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
|
let bytes = result
|
||||||
.body
|
.body
|
||||||
.collect()
|
.collect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to read object body: {}", e))?
|
.map_err(|e| format!("Failed to read object body: {e}"))?
|
||||||
.into_bytes()
|
.into_bytes()
|
||||||
.to_vec();
|
.to_vec();
|
||||||
|
|
||||||
@@ -87,11 +88,12 @@ impl R2Client {
|
|||||||
.content_type("image/png")
|
.content_type("image/png")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to put object to R2: {}", e))?;
|
.map_err(|e| format!("Failed to put object to R2: {e}"))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn object_exists(&self, key: &str) -> bool {
|
pub async fn object_exists(&self, key: &str) -> bool {
|
||||||
self.client
|
self.client
|
||||||
.head_object()
|
.head_object()
|
||||||
|
|||||||
+549
@@ -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<Semaphore>,
|
||||||
|
ip_connections: Arc<DashMap<IpAddr, Arc<Semaphore>>>,
|
||||||
|
pub config: Arc<TarpitConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SocketAddr>) -> 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<Box<dyn Stream<Item = Result<Vec<u8>, std::io::Error>> + Send>>;
|
||||||
|
|
||||||
|
fn create_random_bytes_stream(config: Arc<TarpitConfig>) -> 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<u8> = (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<TarpitConfig>) -> 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!(
|
||||||
|
"<!DOCTYPE html>\n",
|
||||||
|
"<html>\n",
|
||||||
|
"<head>\n",
|
||||||
|
" <title>Admin Panel</title>\n",
|
||||||
|
" <meta charset=\"utf-8\">\n",
|
||||||
|
"</head>\n",
|
||||||
|
"<body>\n",
|
||||||
|
" <h1>Loading...</h1>\n",
|
||||||
|
" <div class=\"content\">\n"
|
||||||
|
)
|
||||||
|
.as_bytes()
|
||||||
|
.to_vec()
|
||||||
|
} else {
|
||||||
|
let elements = [
|
||||||
|
" <div class=\"item\">Processing request...</div>\n",
|
||||||
|
" <span class=\"status\">Initializing...</span>\n",
|
||||||
|
" <!-- Loading data -->\n",
|
||||||
|
" <p>Fetching records...</p>\n",
|
||||||
|
" <div class=\"loader\"></div>\n",
|
||||||
|
" <script>console.log('Loading...');</script>\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<TarpitConfig>) -> 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<Arc<TarpitState>>,
|
||||||
|
peer: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
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::<IpAddr>().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::<IpAddr>().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::<IpAddr>().unwrap());
|
||||||
|
|
||||||
|
// Test fallback to peer address
|
||||||
|
headers.clear();
|
||||||
|
let ip = extract_client_ip(&headers, Some(peer));
|
||||||
|
assert_eq!(ip, "192.0.2.50".parse::<IpAddr>().unwrap());
|
||||||
|
|
||||||
|
// Test fallback to localhost when no peer
|
||||||
|
let ip = extract_client_ip(&headers, None);
|
||||||
|
assert_eq!(ip, "127.0.0.1".parse::<IpAddr>().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { OGImageSpec } from '$lib/og-types';
|
import type { OGImageSpec } from "$lib/og-types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
type: OGImageSpec['type'];
|
type: OGImageSpec["type"];
|
||||||
};
|
};
|
||||||
|
|
||||||
let { title, subtitle, type }: Props = $props();
|
let { title, subtitle, type }: Props = $props();
|
||||||
|
|
||||||
// Calculate font size based on title length (matching original logic)
|
// Calculate font size based on title length (matching original logic)
|
||||||
const fontSize = $derived(title.length > 40 ? '60px' : '72px');
|
const fontSize = $derived(title.length > 40 ? "60px" : "72px");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -20,7 +20,9 @@
|
|||||||
style="display: flex; flex-direction: column; justify-content: space-between; width: 100%; height: 100%;"
|
style="display: flex; flex-direction: column; justify-content: space-between; width: 100%; height: 100%;"
|
||||||
>
|
>
|
||||||
<!-- Content section -->
|
<!-- Content section -->
|
||||||
<div style="display: flex; flex-direction: column; flex: 1; justify-content: center;">
|
<div
|
||||||
|
style="display: flex; flex-direction: column; flex: 1; justify-content: center;"
|
||||||
|
>
|
||||||
<h1
|
<h1
|
||||||
style="font-family: 'Hanken Grotesk', sans-serif; font-weight: 900; font-size: {fontSize}; line-height: 1.1; margin: 0; color: #ffffff;"
|
style="font-family: 'Hanken Grotesk', sans-serif; font-weight: 900; font-size: {fontSize}; line-height: 1.1; margin: 0; color: #ffffff;"
|
||||||
>
|
>
|
||||||
@@ -39,8 +41,10 @@
|
|||||||
<div
|
<div
|
||||||
style="display: flex; justify-content: space-between; align-items: flex-end; border-top: 2px solid #27272a; padding-top: 24px;"
|
style="display: flex; justify-content: space-between; align-items: flex-end; border-top: 2px solid #27272a; padding-top: 24px;"
|
||||||
>
|
>
|
||||||
<div style="font-size: 28px; color: #71717a; font-weight: 500;">xevion.dev</div>
|
<div style="font-size: 28px; color: #71717a; font-weight: 500;">
|
||||||
{#if type === 'project'}
|
xevion.dev
|
||||||
|
</div>
|
||||||
|
{#if type === "project"}
|
||||||
<div
|
<div
|
||||||
style="font-size: 24px; color: #52525b; text-transform: uppercase; letter-spacing: 0.05em;"
|
style="font-size: 24px; color: #52525b; text-transform: uppercase; letter-spacing: 0.05em;"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
class="max-w-2xl mx-4 border-b border-zinc-700 divide-y divide-zinc-700 sm:mx-6"
|
class="max-w-2xl mx-4 border-b border-zinc-700 divide-y divide-zinc-700 sm:mx-6"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col pb-4">
|
<div class="flex flex-col pb-4">
|
||||||
<span class="text-2xl font-bold text-white sm:text-3xl">Ryan Walters,</span>
|
<span class="text-2xl font-bold text-white sm:text-3xl"
|
||||||
|
>Ryan Walters,</span
|
||||||
|
>
|
||||||
<span class="text-xl font-normal text-zinc-400 sm:text-2xl">
|
<span class="text-xl font-normal text-zinc-400 sm:text-2xl">
|
||||||
Full-Stack Software Engineer
|
Full-Stack Software Engineer
|
||||||
</span>
|
</span>
|
||||||
@@ -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"
|
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"
|
||||||
>
|
>
|
||||||
<IconSimpleIconsLinkedin class="size-4 text-zinc-300" />
|
<IconSimpleIconsLinkedin class="size-4 text-zinc-300" />
|
||||||
<span class="whitespace-nowrap text-sm text-zinc-100">LinkedIn</span>
|
<span class="whitespace-nowrap text-sm text-zinc-100">LinkedIn</span
|
||||||
|
>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user