Compare commits

..

8 Commits

Author SHA1 Message Date
cfa958cf3d v25.12.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 12:41:26 +00:00
db2e586da2 feat(proxy-protocol): add PROXY protocol v2 support to the Rust passthrough listener and streamline TypeScript proxy protocol exports 2026-03-19 12:41:26 +00:00
91832c368d v25.11.24
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-17 16:47:57 +00:00
c9d0fccb2d fix(rustproxy-http): improve async static file serving, websocket handshake buffering, and shared metric metadata handling 2026-03-17 16:47:57 +00:00
5dccbbc9d1 v25.11.23
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-17 12:22:51 +00:00
92d7113c6c fix(rustproxy-http,rustproxy-metrics): reduce per-frame metrics overhead by batching body byte accounting 2026-03-17 12:22:51 +00:00
8f6bb30367 v25.11.22
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-17 12:12:24 +00:00
ef9bac80ff fix(rustproxy-http): reuse healthy HTTP/2 upstream connections after requests with bodies 2026-03-17 12:12:24 +00:00
14 changed files with 642 additions and 639 deletions

View File

@@ -1,5 +1,32 @@
# Changelog # Changelog
## 2026-03-19 - 25.12.0 - feat(proxy-protocol)
add PROXY protocol v2 support to the Rust passthrough listener and streamline TypeScript proxy protocol exports
- detect and parse PROXY protocol v2 headers in the Rust TCP listener, including TCP and UDP address families
- add Rust v2 header generation, incomplete-header handling, and broader parser test coverage
- remove deprecated TypeScript proxy protocol parser exports and tests, leaving shared type definitions only
## 2026-03-17 - 25.11.24 - fix(rustproxy-http)
improve async static file serving, websocket handshake buffering, and shared metric metadata handling
- convert static file serving to async filesystem operations and await directory/file checks
- preserve and forward bytes read past the WebSocket handshake header terminator to avoid dropping buffered upstream data
- reuse Arc<str> values for route and source identifiers across counting bodies and metric reporting
- standardize backend key propagation across H1/H2 forwarding, retry, and fallback paths for consistent logging and metrics
## 2026-03-17 - 25.11.23 - fix(rustproxy-http,rustproxy-metrics)
reduce per-frame metrics overhead by batching body byte accounting
- Buffer HTTP body byte counts and flush them every 64 KB, at end of stream, and on drop to keep totals accurate while preserving throughput sampling.
- Skip zero-value counter updates in metrics collection to avoid unnecessary atomic and DashMap operations for the unused direction.
## 2026-03-17 - 25.11.22 - fix(rustproxy-http)
reuse healthy HTTP/2 upstream connections after requests with bodies
- Registers successful HTTP/2 connections in the pool regardless of whether the proxied request included a body
- Continues to avoid pooling upstream connections that returned 502 Bad Gateway responses
## 2026-03-17 - 25.11.21 - fix(rustproxy-http) ## 2026-03-17 - 25.11.21 - fix(rustproxy-http)
reuse pooled HTTP/2 connections for requests with and without bodies reuse pooled HTTP/2 connections for requests with and without bodies

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "25.11.21", "version": "25.12.0",
"private": false, "private": false,
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.", "description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",

View File

@@ -9,19 +9,28 @@ use bytes::Bytes;
use http_body::Frame; use http_body::Frame;
use rustproxy_metrics::MetricsCollector; use rustproxy_metrics::MetricsCollector;
/// Flush accumulated bytes to the metrics collector every 64 KB.
/// This reduces per-frame DashMap shard-locked reads from ~15 to ~1 per 4 frames
/// (assuming typical 16 KB upload frames). The 1 Hz throughput sampler still sees
/// data within one sampling period even at low transfer rates.
const BYTE_FLUSH_THRESHOLD: u64 = 65_536;
/// Wraps any `http_body::Body` and counts data bytes passing through. /// Wraps any `http_body::Body` and counts data bytes passing through.
/// ///
/// Each chunk is reported to the `MetricsCollector` immediately so that /// Bytes are accumulated and flushed to the `MetricsCollector` every
/// the throughput tracker (sampled at 1 Hz) reflects real-time data flow. /// [`BYTE_FLUSH_THRESHOLD`] bytes (and on Drop) so the throughput tracker
/// (sampled at 1 Hz) reflects real-time data flow without per-frame overhead.
/// ///
/// The inner body is pinned on the heap to support `!Unpin` types like `hyper::body::Incoming`. /// The inner body is pinned on the heap to support `!Unpin` types like `hyper::body::Incoming`.
pub struct CountingBody<B> { pub struct CountingBody<B> {
inner: Pin<Box<B>>, inner: Pin<Box<B>>,
metrics: Arc<MetricsCollector>, metrics: Arc<MetricsCollector>,
route_id: Option<String>, route_id: Option<Arc<str>>,
source_ip: Option<String>, source_ip: Option<Arc<str>>,
/// Whether we count bytes as "in" (request body) or "out" (response body). /// Whether we count bytes as "in" (request body) or "out" (response body).
direction: Direction, direction: Direction,
/// Accumulated bytes not yet flushed to the metrics collector.
pending_bytes: u64,
/// Optional connection-level activity tracker. When set, poll_frame updates this /// Optional connection-level activity tracker. When set, poll_frame updates this
/// to keep the idle watchdog alive during active body streaming (uploads/downloads). /// to keep the idle watchdog alive during active body streaming (uploads/downloads).
connection_activity: Option<Arc<AtomicU64>>, connection_activity: Option<Arc<AtomicU64>>,
@@ -47,8 +56,8 @@ impl<B> CountingBody<B> {
pub fn new( pub fn new(
inner: B, inner: B,
metrics: Arc<MetricsCollector>, metrics: Arc<MetricsCollector>,
route_id: Option<String>, route_id: Option<Arc<str>>,
source_ip: Option<String>, source_ip: Option<Arc<str>>,
direction: Direction, direction: Direction,
) -> Self { ) -> Self {
Self { Self {
@@ -57,6 +66,7 @@ impl<B> CountingBody<B> {
route_id, route_id,
source_ip, source_ip,
direction, direction,
pending_bytes: 0,
connection_activity: None, connection_activity: None,
activity_start: None, activity_start: None,
active_requests: None, active_requests: None,
@@ -81,14 +91,19 @@ impl<B> CountingBody<B> {
self self
} }
/// Report a chunk of bytes immediately to the metrics collector. /// Flush accumulated bytes to the metrics collector.
#[inline] #[inline]
fn report_chunk(&self, len: u64) { fn flush_pending(&mut self) {
if self.pending_bytes == 0 {
return;
}
let bytes = self.pending_bytes;
self.pending_bytes = 0;
let route_id = self.route_id.as_deref(); let route_id = self.route_id.as_deref();
let source_ip = self.source_ip.as_deref(); let source_ip = self.source_ip.as_deref();
match self.direction { match self.direction {
Direction::In => self.metrics.record_bytes(len, 0, route_id, source_ip), Direction::In => self.metrics.record_bytes(bytes, 0, route_id, source_ip),
Direction::Out => self.metrics.record_bytes(0, len, route_id, source_ip), Direction::Out => self.metrics.record_bytes(0, bytes, route_id, source_ip),
} }
} }
} }
@@ -113,9 +128,12 @@ where
Poll::Ready(Some(Ok(frame))) => { Poll::Ready(Some(Ok(frame))) => {
if let Some(data) = frame.data_ref() { if let Some(data) = frame.data_ref() {
let len = data.len() as u64; let len = data.len() as u64;
// Report bytes immediately so the 1 Hz throughput sampler sees them this.pending_bytes += len;
this.report_chunk(len); if this.pending_bytes >= BYTE_FLUSH_THRESHOLD {
// Keep the connection-level idle watchdog alive during body streaming this.flush_pending();
}
// Keep the connection-level idle watchdog alive on every frame
// (this is just one atomic store — cheap enough per-frame)
if let (Some(activity), Some(start)) = (&this.connection_activity, &this.activity_start) { if let (Some(activity), Some(start)) = (&this.connection_activity, &this.activity_start) {
activity.store(start.elapsed().as_millis() as u64, Ordering::Relaxed); activity.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
} }
@@ -123,7 +141,11 @@ where
Poll::Ready(Some(Ok(frame))) Poll::Ready(Some(Ok(frame)))
} }
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Ready(None) => Poll::Ready(None), Poll::Ready(None) => {
// End of stream — flush any remaining bytes
this.flush_pending();
Poll::Ready(None)
}
Poll::Pending => Poll::Pending, Poll::Pending => Poll::Pending,
} }
} }
@@ -139,6 +161,8 @@ where
impl<B> Drop for CountingBody<B> { impl<B> Drop for CountingBody<B> {
fn drop(&mut self) { fn drop(&mut self) {
// Flush any remaining accumulated bytes so totals stay accurate
self.flush_pending();
// Decrement the active-request counter so the HTTP idle watchdog // Decrement the active-request counter so the HTTP idle watchdog
// knows this response body is no longer streaming. // knows this response body is no longer streaming.
if let Some(ref counter) = self.active_requests { if let Some(ref counter) = self.active_requests {

View File

@@ -502,7 +502,7 @@ impl HttpProxyService {
// Check for static file serving // Check for static file serving
if let Some(ref advanced) = route_match.route.action.advanced { if let Some(ref advanced) = route_match.route.action.advanced {
if let Some(ref static_files) = advanced.static_files { if let Some(ref static_files) = advanced.static_files {
return Ok(Self::serve_static_file(&path, static_files)); return Ok(Self::serve_static_file(&path, static_files).await);
} }
} }
@@ -615,11 +615,10 @@ impl HttpProxyService {
}; };
// X-Forwarded-For: append client IP to existing chain // X-Forwarded-For: append client IP to existing chain
let client_ip = peer_addr.ip().to_string();
let xff_value = if let Some(existing) = upstream_headers.get("x-forwarded-for") { let xff_value = if let Some(existing) = upstream_headers.get("x-forwarded-for") {
format!("{}, {}", existing.to_str().unwrap_or(""), client_ip) format!("{}, {}", existing.to_str().unwrap_or(""), ip_str)
} else { } else {
client_ip ip_str.clone()
}; };
if let Ok(val) = hyper::header::HeaderValue::from_str(&xff_value) { if let Ok(val) = hyper::header::HeaderValue::from_str(&xff_value) {
upstream_headers.insert( upstream_headers.insert(
@@ -691,7 +690,7 @@ impl HttpProxyService {
self.metrics.set_backend_protocol(&upstream_key, "h2"); self.metrics.set_backend_protocol(&upstream_key, "h2");
let result = self.forward_h2_pooled( let result = self.forward_h2_pooled(
sender, parts, body, upstream_headers, &upstream_path, sender, parts, body, upstream_headers, &upstream_path,
route_match.route, route_id, &ip_str, &pool_key, domain_str, &conn_activity, route_match.route, route_id, &ip_str, &pool_key, domain_str, &conn_activity, &upstream_key,
).await; ).await;
self.upstream_selector.connection_ended(&upstream_key); self.upstream_selector.connection_ended(&upstream_key);
return result; return result;
@@ -844,19 +843,19 @@ impl HttpProxyService {
self.forward_h2_with_fallback( self.forward_h2_with_fallback(
io, parts, body, upstream_headers, &upstream_path, io, parts, body, upstream_headers, &upstream_path,
&upstream, route_match.route, route_id, &ip_str, &final_pool_key, &upstream, route_match.route, route_id, &ip_str, &final_pool_key,
host.clone(), domain_str, &conn_activity, host.clone(), domain_str, &conn_activity, &upstream_key,
).await ).await
} else { } else {
// Explicit H2 mode: hard-fail on handshake error (preserved behavior) // Explicit H2 mode: hard-fail on handshake error (preserved behavior)
self.forward_h2( self.forward_h2(
io, parts, body, upstream_headers, &upstream_path, io, parts, body, upstream_headers, &upstream_path,
&upstream, route_match.route, route_id, &ip_str, &final_pool_key, domain_str, &conn_activity, &upstream, route_match.route, route_id, &ip_str, &final_pool_key, domain_str, &conn_activity, &upstream_key,
).await ).await
} }
} else { } else {
self.forward_h1( self.forward_h1(
io, parts, body, upstream_headers, &upstream_path, io, parts, body, upstream_headers, &upstream_path,
&upstream, route_match.route, route_id, &ip_str, &final_pool_key, domain_str, &conn_activity, &upstream, route_match.route, route_id, &ip_str, &final_pool_key, domain_str, &conn_activity, &upstream_key,
).await ).await
}; };
self.upstream_selector.connection_ended(&upstream_key); self.upstream_selector.connection_ended(&upstream_key);
@@ -880,15 +879,14 @@ impl HttpProxyService {
pool_key: &crate::connection_pool::PoolKey, pool_key: &crate::connection_pool::PoolKey,
domain: &str, domain: &str,
conn_activity: &ConnActivity, conn_activity: &ConnActivity,
backend_key: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let backend_key = format!("{}:{}", pool_key.host, pool_key.port);
// Try pooled H1 connection first — avoids TCP+TLS handshake // Try pooled H1 connection first — avoids TCP+TLS handshake
if let Some(pooled_sender) = self.connection_pool.checkout_h1(pool_key) { if let Some(pooled_sender) = self.connection_pool.checkout_h1(pool_key) {
self.metrics.backend_pool_hit(&backend_key); self.metrics.backend_pool_hit(backend_key);
return self.forward_h1_with_sender( return self.forward_h1_with_sender(
pooled_sender, parts, body, upstream_headers, upstream_path, pooled_sender, parts, body, upstream_headers, upstream_path,
route, route_id, source_ip, pool_key, domain, conn_activity, route, route_id, source_ip, domain, conn_activity, backend_key,
).await; ).await;
} }
@@ -900,7 +898,7 @@ impl HttpProxyService {
Ok(h) => h, Ok(h) => h,
Err(e) => { Err(e) => {
error!(backend = %backend_key, domain = %domain, error = %e, "Backend H1 handshake failed"); error!(backend = %backend_key, domain = %domain, error = %e, "Backend H1 handshake failed");
self.metrics.backend_handshake_error(&backend_key); self.metrics.backend_handshake_error(backend_key);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend handshake failed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend handshake failed"));
} }
}; };
@@ -911,7 +909,7 @@ impl HttpProxyService {
} }
}); });
self.forward_h1_with_sender(sender, parts, body, upstream_headers, upstream_path, route, route_id, source_ip, pool_key, domain, conn_activity).await self.forward_h1_with_sender(sender, parts, body, upstream_headers, upstream_path, route, route_id, source_ip, domain, conn_activity, backend_key).await
} }
/// Common H1 forwarding logic used by both fresh and pooled paths. /// Common H1 forwarding logic used by both fresh and pooled paths.
@@ -925,9 +923,9 @@ impl HttpProxyService {
route: &rustproxy_config::RouteConfig, route: &rustproxy_config::RouteConfig,
route_id: Option<&str>, route_id: Option<&str>,
source_ip: &str, source_ip: &str,
pool_key: &crate::connection_pool::PoolKey,
domain: &str, domain: &str,
conn_activity: &ConnActivity, conn_activity: &ConnActivity,
backend_key: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
// Always use HTTP/1.1 for h1 backend connections (h2 incoming requests have version HTTP/2.0) // Always use HTTP/1.1 for h1 backend connections (h2 incoming requests have version HTTP/2.0)
let mut upstream_req = Request::builder() let mut upstream_req = Request::builder()
@@ -939,12 +937,16 @@ impl HttpProxyService {
*headers = upstream_headers; *headers = upstream_headers;
} }
// Compute Arc<str> once for both request and response CountingBody
let rid: Option<Arc<str>> = route_id.map(Arc::from);
let sip: Arc<str> = Arc::from(source_ip);
// Wrap the request body in CountingBody then box it for the uniform pool type // Wrap the request body in CountingBody then box it for the uniform pool type
let counting_req_body = CountingBody::new( let counting_req_body = CountingBody::new(
body, body,
Arc::clone(&self.metrics), Arc::clone(&self.metrics),
route_id.map(|s| s.to_string()), rid.clone(),
Some(source_ip.to_string()), Some(Arc::clone(&sip)),
Direction::In, Direction::In,
).with_connection_activity(Arc::clone(&conn_activity.last_activity), conn_activity.start); ).with_connection_activity(Arc::clone(&conn_activity.last_activity), conn_activity.start);
let boxed_body: BoxBody<Bytes, hyper::Error> = BoxBody::new(counting_req_body); let boxed_body: BoxBody<Bytes, hyper::Error> = BoxBody::new(counting_req_body);
@@ -954,9 +956,8 @@ impl HttpProxyService {
let upstream_response = match sender.send_request(upstream_req).await { let upstream_response = match sender.send_request(upstream_req).await {
Ok(resp) => resp, Ok(resp) => resp,
Err(e) => { Err(e) => {
let bk = format!("{}:{}", pool_key.host, pool_key.port); error!(backend = %backend_key, domain = %domain, error = %e, "Backend H1 request failed");
error!(backend = %bk, domain = %domain, error = %e, "Backend H1 request failed"); self.metrics.backend_request_error(backend_key);
self.metrics.backend_request_error(&bk);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend request failed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend request failed"));
} }
}; };
@@ -971,7 +972,7 @@ impl HttpProxyService {
// of large streaming responses (e.g. 352MB Docker layers) takes priority. // of large streaming responses (e.g. 352MB Docker layers) takes priority.
drop(sender); drop(sender);
self.build_streaming_response(upstream_response, route, route_id, source_ip, conn_activity).await self.build_streaming_response(upstream_response, route, rid, sip, conn_activity).await
} }
/// Forward request to backend via HTTP/2 with body streaming (fresh connection). /// Forward request to backend via HTTP/2 with body streaming (fresh connection).
@@ -990,8 +991,8 @@ impl HttpProxyService {
pool_key: &crate::connection_pool::PoolKey, pool_key: &crate::connection_pool::PoolKey,
domain: &str, domain: &str,
conn_activity: &ConnActivity, conn_activity: &ConnActivity,
backend_key: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let backend_key = format!("{}:{}", pool_key.host, pool_key.port);
let exec = hyper_util::rt::TokioExecutor::new(); let exec = hyper_util::rt::TokioExecutor::new();
let mut h2_builder = hyper::client::conn::http2::Builder::new(exec); let mut h2_builder = hyper::client::conn::http2::Builder::new(exec);
h2_builder h2_builder
@@ -1007,12 +1008,12 @@ impl HttpProxyService {
Ok(Ok(h)) => h, Ok(Ok(h)) => h,
Ok(Err(e)) => { Ok(Err(e)) => {
error!(backend = %backend_key, domain = %domain, error = %e, error_debug = ?e, "Backend H2 handshake failed"); error!(backend = %backend_key, domain = %domain, error = %e, error_debug = ?e, "Backend H2 handshake failed");
self.metrics.backend_handshake_error(&backend_key); self.metrics.backend_handshake_error(backend_key);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H2 handshake failed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H2 handshake failed"));
} }
Err(_) => { Err(_) => {
error!(backend = %backend_key, domain = %domain, "Backend H2 handshake timeout"); error!(backend = %backend_key, domain = %domain, "Backend H2 handshake timeout");
self.metrics.backend_handshake_error(&backend_key); self.metrics.backend_handshake_error(backend_key);
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend H2 handshake timeout")); return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend H2 handshake timeout"));
} }
}; };
@@ -1038,12 +1039,9 @@ impl HttpProxyService {
}); });
} }
// Only pool the H2 connection if the request had no body.
// Requests with bodies (uploads) deplete connection-level flow control windows.
let request_had_body = !body.is_end_stream();
let sender_for_pool = sender.clone(); let sender_for_pool = sender.clone();
let result = self.forward_h2_with_sender(sender, parts, body, upstream_headers, upstream_path, route, route_id, source_ip, Some(pool_key), domain, conn_activity).await; let result = self.forward_h2_with_sender(sender, parts, body, upstream_headers, upstream_path, route, route_id, source_ip, Some(pool_key), domain, conn_activity, backend_key).await;
if !request_had_body && matches!(&result, Ok(ref resp) if resp.status() != StatusCode::BAD_GATEWAY) { if matches!(&result, Ok(ref resp) if resp.status() != StatusCode::BAD_GATEWAY) {
let g = self.connection_pool.register_h2(pool_key.clone(), sender_for_pool); let g = self.connection_pool.register_h2(pool_key.clone(), sender_for_pool);
gen_holder.store(g, std::sync::atomic::Ordering::Relaxed); gen_holder.store(g, std::sync::atomic::Ordering::Relaxed);
} }
@@ -1066,6 +1064,7 @@ impl HttpProxyService {
pool_key: &crate::connection_pool::PoolKey, pool_key: &crate::connection_pool::PoolKey,
domain: &str, domain: &str,
conn_activity: &ConnActivity, conn_activity: &ConnActivity,
backend_key: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
// Save retry state for bodyless requests (cheap: Method is an enum, HeaderMap clones Arc-backed Bytes) // Save retry state for bodyless requests (cheap: Method is an enum, HeaderMap clones Arc-backed Bytes)
let retry_state = if body.is_end_stream() { let retry_state = if body.is_end_stream() {
@@ -1076,18 +1075,18 @@ impl HttpProxyService {
let result = self.forward_h2_with_sender( let result = self.forward_h2_with_sender(
sender, parts, body, upstream_headers, upstream_path, sender, parts, body, upstream_headers, upstream_path,
route, route_id, source_ip, Some(pool_key), domain, conn_activity, route, route_id, source_ip, Some(pool_key), domain, conn_activity, backend_key,
).await; ).await;
// If the request failed (502) and we can retry with an empty body, do so // If the request failed (502) and we can retry with an empty body, do so
let is_502 = matches!(&result, Ok(resp) if resp.status() == StatusCode::BAD_GATEWAY); let is_502 = matches!(&result, Ok(resp) if resp.status() == StatusCode::BAD_GATEWAY);
if is_502 { if is_502 {
if let Some((method, headers)) = retry_state { if let Some((method, headers)) = retry_state {
warn!(backend = %format!("{}:{}", pool_key.host, pool_key.port), domain = %domain, warn!(backend = %backend_key, domain = %domain,
"Stale pooled H2 sender, retrying with fresh connection"); "Stale pooled H2 sender, retrying with fresh connection");
return self.retry_h2_with_fresh_connection( return self.retry_h2_with_fresh_connection(
method, headers, upstream_path, method, headers, upstream_path,
pool_key, route, route_id, source_ip, domain, conn_activity, pool_key, route, route_id, source_ip, domain, conn_activity, backend_key,
).await; ).await;
} }
} }
@@ -1107,8 +1106,8 @@ impl HttpProxyService {
source_ip: &str, source_ip: &str,
domain: &str, domain: &str,
conn_activity: &ConnActivity, conn_activity: &ConnActivity,
backend_key: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let backend_key = format!("{}:{}", pool_key.host, pool_key.port);
// Establish fresh backend connection // Establish fresh backend connection
let retry_connect_start = std::time::Instant::now(); let retry_connect_start = std::time::Instant::now();
@@ -1120,12 +1119,12 @@ impl HttpProxyService {
Ok(Ok(tls)) => BackendStream::Tls(tls), Ok(Ok(tls)) => BackendStream::Tls(tls),
Ok(Err(e)) => { Ok(Err(e)) => {
error!(backend = %backend_key, domain = %domain, error = %e, "H2 retry: TLS connect failed"); error!(backend = %backend_key, domain = %domain, error = %e, "H2 retry: TLS connect failed");
self.metrics.backend_connect_error(&backend_key); self.metrics.backend_connect_error(backend_key);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable on H2 retry")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable on H2 retry"));
} }
Err(_) => { Err(_) => {
error!(backend = %backend_key, domain = %domain, "H2 retry: TLS connect timeout"); error!(backend = %backend_key, domain = %domain, "H2 retry: TLS connect timeout");
self.metrics.backend_connect_error(&backend_key); self.metrics.backend_connect_error(backend_key);
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend timeout on H2 retry")); return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend timeout on H2 retry"));
} }
} }
@@ -1140,17 +1139,17 @@ impl HttpProxyService {
} }
Ok(Err(e)) => { Ok(Err(e)) => {
error!(backend = %backend_key, domain = %domain, error = %e, "H2 retry: TCP connect failed"); error!(backend = %backend_key, domain = %domain, error = %e, "H2 retry: TCP connect failed");
self.metrics.backend_connect_error(&backend_key); self.metrics.backend_connect_error(backend_key);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable on H2 retry")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable on H2 retry"));
} }
Err(_) => { Err(_) => {
error!(backend = %backend_key, domain = %domain, "H2 retry: TCP connect timeout"); error!(backend = %backend_key, domain = %domain, "H2 retry: TCP connect timeout");
self.metrics.backend_connect_error(&backend_key); self.metrics.backend_connect_error(backend_key);
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend timeout on H2 retry")); return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend timeout on H2 retry"));
} }
} }
}; };
self.metrics.backend_connection_opened(&backend_key, retry_connect_start.elapsed()); self.metrics.backend_connection_opened(backend_key, retry_connect_start.elapsed());
let io = TokioIo::new(backend); let io = TokioIo::new(backend);
let exec = hyper_util::rt::TokioExecutor::new(); let exec = hyper_util::rt::TokioExecutor::new();
@@ -1168,14 +1167,14 @@ impl HttpProxyService {
Ok(Ok(h)) => h, Ok(Ok(h)) => h,
Ok(Err(e)) => { Ok(Err(e)) => {
error!(backend = %backend_key, domain = %domain, error = %e, error_debug = ?e, "H2 retry: handshake failed"); error!(backend = %backend_key, domain = %domain, error = %e, error_debug = ?e, "H2 retry: handshake failed");
self.metrics.backend_handshake_error(&backend_key); self.metrics.backend_handshake_error(backend_key);
self.metrics.backend_connection_closed(&backend_key); self.metrics.backend_connection_closed(backend_key);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H2 retry handshake failed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H2 retry handshake failed"));
} }
Err(_) => { Err(_) => {
error!(backend = %backend_key, domain = %domain, "H2 retry: handshake timeout"); error!(backend = %backend_key, domain = %domain, "H2 retry: handshake timeout");
self.metrics.backend_handshake_error(&backend_key); self.metrics.backend_handshake_error(backend_key);
self.metrics.backend_connection_closed(&backend_key); self.metrics.backend_connection_closed(backend_key);
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend H2 retry handshake timeout")); return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend H2 retry handshake timeout"));
} }
}; };
@@ -1223,16 +1222,16 @@ impl HttpProxyService {
// Register in pool only after request succeeds // Register in pool only after request succeeds
let g = self.connection_pool.register_h2(pool_key.clone(), sender); let g = self.connection_pool.register_h2(pool_key.clone(), sender);
gen_holder.store(g, std::sync::atomic::Ordering::Relaxed); gen_holder.store(g, std::sync::atomic::Ordering::Relaxed);
let result = self.build_streaming_response(resp, route, route_id, source_ip, conn_activity).await; let result = self.build_streaming_response(resp, route, route_id.map(Arc::from), Arc::from(source_ip), conn_activity).await;
// Close the fresh backend connection (opened above) // Close the fresh backend connection (opened above)
self.metrics.backend_connection_closed(&backend_key); self.metrics.backend_connection_closed(backend_key);
result result
} }
Err(e) => { Err(e) => {
error!(backend = %backend_key, domain = %domain, error = %e, "H2 retry: request failed"); error!(backend = %backend_key, domain = %domain, error = %e, "H2 retry: request failed");
self.metrics.backend_request_error(&backend_key); self.metrics.backend_request_error(backend_key);
// Close the fresh backend connection (opened above) // Close the fresh backend connection (opened above)
self.metrics.backend_connection_closed(&backend_key); self.metrics.backend_connection_closed(backend_key);
Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H2 request failed on retry")) Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H2 request failed on retry"))
} }
} }
@@ -1260,6 +1259,7 @@ impl HttpProxyService {
requested_host: Option<String>, requested_host: Option<String>,
domain: &str, domain: &str,
conn_activity: &ConnActivity, conn_activity: &ConnActivity,
backend_key: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let exec = hyper_util::rt::TokioExecutor::new(); let exec = hyper_util::rt::TokioExecutor::new();
let mut h2_builder = hyper::client::conn::http2::Builder::new(exec); let mut h2_builder = hyper::client::conn::http2::Builder::new(exec);
@@ -1277,14 +1277,13 @@ impl HttpProxyService {
match handshake_result { match handshake_result {
Err(_) => { Err(_) => {
// H2 handshake timed out — fall back to H1 // H2 handshake timed out — fall back to H1
let bk = format!("{}:{}", upstream.host, upstream.port);
warn!( warn!(
backend = %bk, backend = %backend_key,
domain = %domain, domain = %domain,
"H2 handshake timeout, falling back to H1" "H2 handshake timeout, falling back to H1"
); );
self.metrics.backend_h2_failure(&bk); self.metrics.backend_h2_failure(backend_key);
self.metrics.backend_handshake_error(&bk); self.metrics.backend_handshake_error(backend_key);
let cache_key = crate::protocol_cache::ProtocolCacheKey { let cache_key = crate::protocol_cache::ProtocolCacheKey {
host: upstream.host.clone(), host: upstream.host.clone(),
@@ -1293,7 +1292,7 @@ impl HttpProxyService {
}; };
self.protocol_cache.insert(cache_key, crate::protocol_cache::DetectedProtocol::H1); self.protocol_cache.insert(cache_key, crate::protocol_cache::DetectedProtocol::H1);
match self.reconnect_backend(upstream, domain).await { match self.reconnect_backend(upstream, domain, backend_key).await {
Some(fallback_backend) => { Some(fallback_backend) => {
let h1_pool_key = crate::connection_pool::PoolKey { let h1_pool_key = crate::connection_pool::PoolKey {
host: upstream.host.clone(), host: upstream.host.clone(),
@@ -1304,9 +1303,9 @@ impl HttpProxyService {
let fallback_io = TokioIo::new(fallback_backend); let fallback_io = TokioIo::new(fallback_backend);
let result = self.forward_h1( let result = self.forward_h1(
fallback_io, parts, body, upstream_headers, upstream_path, fallback_io, parts, body, upstream_headers, upstream_path,
upstream, route, route_id, source_ip, &h1_pool_key, domain, conn_activity, upstream, route, route_id, source_ip, &h1_pool_key, domain, conn_activity, backend_key,
).await; ).await;
self.metrics.backend_connection_closed(&bk); self.metrics.backend_connection_closed(backend_key);
result result
} }
None => { None => {
@@ -1357,11 +1356,13 @@ impl HttpProxyService {
*headers = upstream_headers; *headers = upstream_headers;
} }
let rid: Option<Arc<str>> = route_id.map(Arc::from);
let sip: Arc<str> = Arc::from(source_ip);
let counting_req_body = CountingBody::new( let counting_req_body = CountingBody::new(
body, body,
Arc::clone(&self.metrics), Arc::clone(&self.metrics),
route_id.map(|s| s.to_string()), rid.clone(),
Some(source_ip.to_string()), Some(Arc::clone(&sip)),
Direction::In, Direction::In,
).with_connection_activity(Arc::clone(&conn_activity.last_activity), conn_activity.start); ).with_connection_activity(Arc::clone(&conn_activity.last_activity), conn_activity.start);
let boxed_body: BoxBody<Bytes, hyper::Error> = BoxBody::new(counting_req_body); let boxed_body: BoxBody<Bytes, hyper::Error> = BoxBody::new(counting_req_body);
@@ -1371,40 +1372,33 @@ impl HttpProxyService {
Ok(upstream_response) => { Ok(upstream_response) => {
let g = self.connection_pool.register_h2(pool_key.clone(), sender); let g = self.connection_pool.register_h2(pool_key.clone(), sender);
gen_holder.store(g, std::sync::atomic::Ordering::Relaxed); gen_holder.store(g, std::sync::atomic::Ordering::Relaxed);
self.build_streaming_response(upstream_response, route, route_id, source_ip, conn_activity).await self.build_streaming_response(upstream_response, route, rid, sip, conn_activity).await
} }
Err(e) => { Err(e) => {
// H2 request failed on a stream level (e.g. RST_STREAM PROTOCOL_ERROR). // H2 request failed on a stream level (e.g. RST_STREAM PROTOCOL_ERROR).
// The H2 handshake succeeded, so the backend genuinely speaks H2 — don't // The H2 handshake succeeded, so the backend genuinely speaks H2 — don't
// poison the protocol cache. Only handshake-level failures (below) should // poison the protocol cache. Only handshake-level failures (below) should
// downgrade the cache to H1. // downgrade the cache to H1.
let bk = format!("{}:{}", upstream.host, upstream.port);
debug!( debug!(
backend = %bk, backend = %backend_key,
domain = %domain, domain = %domain,
error = %e, error = %e,
error_debug = ?e, error_debug = ?e,
"H2 stream error, retrying this request as H1" "H2 stream error, retrying this request as H1"
); );
self.metrics.backend_h2_failure(&bk); self.metrics.backend_h2_failure(backend_key);
// Retry as H1 for bodyless requests; return 502 for requests with bodies // Retry as H1 for bodyless requests; return 502 for requests with bodies
if let Some((method, headers)) = retry_state { if let Some((method, headers)) = retry_state {
match self.reconnect_backend(upstream, domain).await { match self.reconnect_backend(upstream, domain, backend_key).await {
Some(fallback_backend) => { Some(fallback_backend) => {
let h1_pool_key = crate::connection_pool::PoolKey {
host: upstream.host.clone(),
port: upstream.port,
use_tls: upstream.use_tls,
h2: false,
};
let fallback_io = TokioIo::new(fallback_backend); let fallback_io = TokioIo::new(fallback_backend);
let result = self.forward_h1_empty_body( let result = self.forward_h1_empty_body(
fallback_io, method, headers, upstream_path, fallback_io, method, headers, upstream_path,
route, route_id, source_ip, &h1_pool_key, domain, conn_activity, route, route_id, source_ip, domain, conn_activity, backend_key,
).await; ).await;
// Close the reconnected backend connection (opened in reconnect_backend) // Close the reconnected backend connection (opened in reconnect_backend)
self.metrics.backend_connection_closed(&bk); self.metrics.backend_connection_closed(backend_key);
result result
} }
None => { None => {
@@ -1420,15 +1414,14 @@ impl HttpProxyService {
Ok(Err(e)) => { Ok(Err(e)) => {
// H2 handshake truly failed — fall back to H1 // H2 handshake truly failed — fall back to H1
// Body is NOT consumed yet, so we can retry the full request. // Body is NOT consumed yet, so we can retry the full request.
let bk = format!("{}:{}", upstream.host, upstream.port);
warn!( warn!(
backend = %bk, backend = %backend_key,
domain = %domain, domain = %domain,
error = %e, error = %e,
"H2 handshake failed, falling back to H1" "H2 handshake failed, falling back to H1"
); );
self.metrics.backend_h2_failure(&bk); self.metrics.backend_h2_failure(backend_key);
self.metrics.backend_handshake_error(&bk); self.metrics.backend_handshake_error(backend_key);
// Update cache to H1 so subsequent requests skip H2 // Update cache to H1 so subsequent requests skip H2
let cache_key = crate::protocol_cache::ProtocolCacheKey { let cache_key = crate::protocol_cache::ProtocolCacheKey {
@@ -1439,7 +1432,7 @@ impl HttpProxyService {
self.protocol_cache.insert(cache_key, crate::protocol_cache::DetectedProtocol::H1); self.protocol_cache.insert(cache_key, crate::protocol_cache::DetectedProtocol::H1);
// Reconnect for H1 (the original io was consumed by the failed h2 handshake) // Reconnect for H1 (the original io was consumed by the failed h2 handshake)
match self.reconnect_backend(upstream, domain).await { match self.reconnect_backend(upstream, domain, backend_key).await {
Some(fallback_backend) => { Some(fallback_backend) => {
let h1_pool_key = crate::connection_pool::PoolKey { let h1_pool_key = crate::connection_pool::PoolKey {
host: upstream.host.clone(), host: upstream.host.clone(),
@@ -1450,10 +1443,10 @@ impl HttpProxyService {
let fallback_io = TokioIo::new(fallback_backend); let fallback_io = TokioIo::new(fallback_backend);
let result = self.forward_h1( let result = self.forward_h1(
fallback_io, parts, body, upstream_headers, upstream_path, fallback_io, parts, body, upstream_headers, upstream_path,
upstream, route, route_id, source_ip, &h1_pool_key, domain, conn_activity, upstream, route, route_id, source_ip, &h1_pool_key, domain, conn_activity, backend_key,
).await; ).await;
// Close the reconnected backend connection (opened in reconnect_backend) // Close the reconnected backend connection (opened in reconnect_backend)
self.metrics.backend_connection_closed(&bk); self.metrics.backend_connection_closed(backend_key);
result result
} }
None => { None => {
@@ -1475,11 +1468,10 @@ impl HttpProxyService {
route: &rustproxy_config::RouteConfig, route: &rustproxy_config::RouteConfig,
route_id: Option<&str>, route_id: Option<&str>,
source_ip: &str, source_ip: &str,
pool_key: &crate::connection_pool::PoolKey,
domain: &str, domain: &str,
conn_activity: &ConnActivity, conn_activity: &ConnActivity,
backend_key: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let backend_key = format!("{}:{}", pool_key.host, pool_key.port);
let (mut sender, conn): ( let (mut sender, conn): (
hyper::client::conn::http1::SendRequest<BoxBody<Bytes, hyper::Error>>, hyper::client::conn::http1::SendRequest<BoxBody<Bytes, hyper::Error>>,
hyper::client::conn::http1::Connection<TokioIo<BackendStream>, BoxBody<Bytes, hyper::Error>>, hyper::client::conn::http1::Connection<TokioIo<BackendStream>, BoxBody<Bytes, hyper::Error>>,
@@ -1487,7 +1479,7 @@ impl HttpProxyService {
Ok(h) => h, Ok(h) => h,
Err(e) => { Err(e) => {
error!(backend = %backend_key, domain = %domain, error = %e, "H1 fallback: handshake failed"); error!(backend = %backend_key, domain = %domain, error = %e, "H1 fallback: handshake failed");
self.metrics.backend_handshake_error(&backend_key); self.metrics.backend_handshake_error(backend_key);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H1 fallback handshake failed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H1 fallback handshake failed"));
} }
}; };
@@ -1516,7 +1508,7 @@ impl HttpProxyService {
Ok(resp) => resp, Ok(resp) => resp,
Err(e) => { Err(e) => {
error!(backend = %backend_key, domain = %domain, error = %e, "H1 fallback: request failed"); error!(backend = %backend_key, domain = %domain, error = %e, "H1 fallback: request failed");
self.metrics.backend_request_error(&backend_key); self.metrics.backend_request_error(backend_key);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H1 fallback request failed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H1 fallback request failed"));
} }
}; };
@@ -1524,7 +1516,7 @@ impl HttpProxyService {
// Don't pool the sender while response body is still streaming (same safety as forward_h1_with_sender) // Don't pool the sender while response body is still streaming (same safety as forward_h1_with_sender)
drop(sender); drop(sender);
self.build_streaming_response(upstream_response, route, route_id, source_ip, conn_activity).await self.build_streaming_response(upstream_response, route, route_id.map(Arc::from), Arc::from(source_ip), conn_activity).await
} }
/// Reconnect to a backend (used for H2→H1 fallback). /// Reconnect to a backend (used for H2→H1 fallback).
@@ -1532,8 +1524,8 @@ impl HttpProxyService {
&self, &self,
upstream: &crate::upstream_selector::UpstreamSelection, upstream: &crate::upstream_selector::UpstreamSelection,
domain: &str, domain: &str,
backend_key: &str,
) -> Option<BackendStream> { ) -> Option<BackendStream> {
let backend_key = format!("{}:{}", upstream.host, upstream.port);
let reconnect_start = std::time::Instant::now(); let reconnect_start = std::time::Instant::now();
if upstream.use_tls { if upstream.use_tls {
match tokio::time::timeout( match tokio::time::timeout(
@@ -1541,17 +1533,17 @@ impl HttpProxyService {
connect_tls_backend(&self.backend_tls_config, &upstream.host, upstream.port), connect_tls_backend(&self.backend_tls_config, &upstream.host, upstream.port),
).await { ).await {
Ok(Ok(tls)) => { Ok(Ok(tls)) => {
self.metrics.backend_connection_opened(&backend_key, reconnect_start.elapsed()); self.metrics.backend_connection_opened(backend_key, reconnect_start.elapsed());
Some(BackendStream::Tls(tls)) Some(BackendStream::Tls(tls))
} }
Ok(Err(e)) => { Ok(Err(e)) => {
error!(backend = %backend_key, domain = %domain, error = %e, "H1 fallback: TLS reconnect failed"); error!(backend = %backend_key, domain = %domain, error = %e, "H1 fallback: TLS reconnect failed");
self.metrics.backend_connect_error(&backend_key); self.metrics.backend_connect_error(backend_key);
None None
} }
Err(_) => { Err(_) => {
error!(backend = %backend_key, domain = %domain, "H1 fallback: TLS reconnect timeout"); error!(backend = %backend_key, domain = %domain, "H1 fallback: TLS reconnect timeout");
self.metrics.backend_connect_error(&backend_key); self.metrics.backend_connect_error(backend_key);
None None
} }
} }
@@ -1565,17 +1557,17 @@ impl HttpProxyService {
let _ = socket2::SockRef::from(&s).set_tcp_keepalive( let _ = socket2::SockRef::from(&s).set_tcp_keepalive(
&socket2::TcpKeepalive::new().with_time(std::time::Duration::from_secs(60)) &socket2::TcpKeepalive::new().with_time(std::time::Duration::from_secs(60))
); );
self.metrics.backend_connection_opened(&backend_key, reconnect_start.elapsed()); self.metrics.backend_connection_opened(backend_key, reconnect_start.elapsed());
Some(BackendStream::Plain(s)) Some(BackendStream::Plain(s))
} }
Ok(Err(e)) => { Ok(Err(e)) => {
error!(backend = %backend_key, domain = %domain, error = %e, "H1 fallback: TCP reconnect failed"); error!(backend = %backend_key, domain = %domain, error = %e, "H1 fallback: TCP reconnect failed");
self.metrics.backend_connect_error(&backend_key); self.metrics.backend_connect_error(backend_key);
None None
} }
Err(_) => { Err(_) => {
error!(backend = %backend_key, domain = %domain, "H1 fallback: TCP reconnect timeout"); error!(backend = %backend_key, domain = %domain, "H1 fallback: TCP reconnect timeout");
self.metrics.backend_connect_error(&backend_key); self.metrics.backend_connect_error(backend_key);
None None
} }
} }
@@ -1596,6 +1588,7 @@ impl HttpProxyService {
pool_key: Option<&crate::connection_pool::PoolKey>, pool_key: Option<&crate::connection_pool::PoolKey>,
domain: &str, domain: &str,
conn_activity: &ConnActivity, conn_activity: &ConnActivity,
backend_key: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
// Build absolute URI for H2 pseudo-headers (:scheme, :authority) // Build absolute URI for H2 pseudo-headers (:scheme, :authority)
// Use the requested domain as authority (not backend address) so :authority matches Host header // Use the requested domain as authority (not backend address) so :authority matches Host header
@@ -1617,12 +1610,16 @@ impl HttpProxyService {
*headers = upstream_headers; *headers = upstream_headers;
} }
// Compute Arc<str> once for both request and response CountingBody
let rid: Option<Arc<str>> = route_id.map(Arc::from);
let sip: Arc<str> = Arc::from(source_ip);
// Wrap the request body in CountingBody then box it for the uniform pool type // Wrap the request body in CountingBody then box it for the uniform pool type
let counting_req_body = CountingBody::new( let counting_req_body = CountingBody::new(
body, body,
Arc::clone(&self.metrics), Arc::clone(&self.metrics),
route_id.map(|s| s.to_string()), rid.clone(),
Some(source_ip.to_string()), Some(Arc::clone(&sip)),
Direction::In, Direction::In,
).with_connection_activity(Arc::clone(&conn_activity.last_activity), conn_activity.start); ).with_connection_activity(Arc::clone(&conn_activity.last_activity), conn_activity.start);
let boxed_body: BoxBody<Bytes, hyper::Error> = BoxBody::new(counting_req_body); let boxed_body: BoxBody<Bytes, hyper::Error> = BoxBody::new(counting_req_body);
@@ -1634,9 +1631,8 @@ impl HttpProxyService {
Err(e) => { Err(e) => {
// Evict the dead sender so subsequent requests get fresh connections // Evict the dead sender so subsequent requests get fresh connections
if let Some(key) = pool_key { if let Some(key) = pool_key {
let bk = format!("{}:{}", key.host, key.port); error!(backend = %backend_key, domain = %domain, error = %e, error_debug = ?e, "Backend H2 request failed");
error!(backend = %bk, domain = %domain, error = %e, error_debug = ?e, "Backend H2 request failed"); self.metrics.backend_request_error(backend_key);
self.metrics.backend_request_error(&bk);
self.connection_pool.remove_h2(key); self.connection_pool.remove_h2(key);
} else { } else {
error!(domain = %domain, error = %e, error_debug = ?e, "Backend H2 request failed"); error!(domain = %domain, error = %e, error_debug = ?e, "Backend H2 request failed");
@@ -1645,7 +1641,7 @@ impl HttpProxyService {
} }
}; };
self.build_streaming_response(upstream_response, route, route_id, source_ip, conn_activity).await self.build_streaming_response(upstream_response, route, rid, sip, conn_activity).await
} }
/// Build the client-facing response from an upstream response, streaming the body. /// Build the client-facing response from an upstream response, streaming the body.
@@ -1656,8 +1652,8 @@ impl HttpProxyService {
&self, &self,
upstream_response: Response<Incoming>, upstream_response: Response<Incoming>,
route: &rustproxy_config::RouteConfig, route: &rustproxy_config::RouteConfig,
route_id: Option<&str>, route_id: Option<Arc<str>>,
source_ip: &str, source_ip: Arc<str>,
conn_activity: &ConnActivity, conn_activity: &ConnActivity,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let (resp_parts, resp_body) = upstream_response.into_parts(); let (resp_parts, resp_body) = upstream_response.into_parts();
@@ -1689,8 +1685,8 @@ impl HttpProxyService {
let counting_body = CountingBody::new( let counting_body = CountingBody::new(
resp_body, resp_body,
Arc::clone(&self.metrics), Arc::clone(&self.metrics),
route_id.map(|s| s.to_string()), route_id,
Some(source_ip.to_string()), Some(source_ip),
Direction::Out, Direction::Out,
).with_connection_activity(Arc::clone(&conn_activity.last_activity), conn_activity.start); ).with_connection_activity(Arc::clone(&conn_activity.last_activity), conn_activity.start);
@@ -1909,21 +1905,26 @@ impl HttpProxyService {
} }
let mut response_buf = Vec::with_capacity(4096); let mut response_buf = Vec::with_capacity(4096);
let mut temp = [0u8; 1]; let mut read_buf = [0u8; 4096];
let extra_bytes: Vec<u8>;
loop { loop {
match upstream_stream.read(&mut temp).await { match upstream_stream.read(&mut read_buf).await {
Ok(0) => { Ok(0) => {
error!("WebSocket: upstream closed before completing handshake"); error!("WebSocket: upstream closed before completing handshake");
self.upstream_selector.connection_ended(upstream_key); self.upstream_selector.connection_ended(upstream_key);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend closed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend closed"));
} }
Ok(_) => { Ok(n) => {
response_buf.push(temp[0]); let prev_len = response_buf.len();
if response_buf.len() >= 4 { response_buf.extend_from_slice(&read_buf[..n]);
let len = response_buf.len(); // Scan for \r\n\r\n, backing up 3 bytes to handle split across reads
if response_buf[len-4..] == *b"\r\n\r\n" { let search_start = prev_len.saturating_sub(3);
break; if let Some(pos) = response_buf[search_start..].windows(4)
} .position(|w| w == b"\r\n\r\n")
{
let header_end = search_start + pos + 4;
extra_bytes = response_buf.split_off(header_end);
break;
} }
if response_buf.len() > 8192 { if response_buf.len() > 8192 {
error!("WebSocket: upstream response headers too large"); error!("WebSocket: upstream response headers too large");
@@ -1998,8 +1999,8 @@ impl HttpProxyService {
); );
let metrics = Arc::clone(&self.metrics); let metrics = Arc::clone(&self.metrics);
let route_id_owned = route_id.map(|s| s.to_string()); let route_id_owned: Option<Arc<str>> = route_id.map(Arc::from);
let source_ip_owned = source_ip.to_string(); let source_ip_owned: Arc<str> = Arc::from(source_ip);
let upstream_selector = self.upstream_selector.clone(); let upstream_selector = self.upstream_selector.clone();
let upstream_key_owned = upstream_key.to_string(); let upstream_key_owned = upstream_key.to_string();
let ws_inactivity_timeout = self.ws_inactivity_timeout; let ws_inactivity_timeout = self.ws_inactivity_timeout;
@@ -2053,7 +2054,7 @@ impl HttpProxyService {
break; break;
} }
total += n as u64; total += n as u64;
metrics_c2u.record_bytes(n as u64, 0, route_c2u.as_deref(), Some(&ip_c2u)); metrics_c2u.record_bytes(n as u64, 0, route_c2u.as_deref(), Some(&*ip_c2u));
la1.store(start.elapsed().as_millis() as u64, Ordering::Relaxed); la1.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
if let Some((ref ca, ca_start)) = conn_act_c2u { if let Some((ref ca, ca_start)) = conn_act_c2u {
ca.store(ca_start.elapsed().as_millis() as u64, Ordering::Relaxed); ca.store(ca_start.elapsed().as_millis() as u64, Ordering::Relaxed);
@@ -2075,6 +2076,23 @@ impl HttpProxyService {
let u2c = tokio::spawn(async move { let u2c = tokio::spawn(async move {
let mut buf = vec![0u8; 65536]; let mut buf = vec![0u8; 65536];
let mut total = 0u64; let mut total = 0u64;
// Forward any bytes buffered past the HTTP header terminator during handshake
if !extra_bytes.is_empty() {
let n = extra_bytes.len();
if cw.write_all(&extra_bytes).await.is_err() {
let _ = tokio::time::timeout(
std::time::Duration::from_secs(2),
cw.shutdown(),
).await;
return 0u64;
}
total += n as u64;
metrics_u2c.record_bytes(0, n as u64, route_u2c.as_deref(), Some(&*ip_u2c));
la2.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
if let Some((ref ca, ca_start)) = conn_act_u2c {
ca.store(ca_start.elapsed().as_millis() as u64, Ordering::Relaxed);
}
}
loop { loop {
let n = tokio::select! { let n = tokio::select! {
result = ur.read(&mut buf) => match result { result = ur.read(&mut buf) => match result {
@@ -2087,7 +2105,7 @@ impl HttpProxyService {
break; break;
} }
total += n as u64; total += n as u64;
metrics_u2c.record_bytes(0, n as u64, route_u2c.as_deref(), Some(&ip_u2c)); metrics_u2c.record_bytes(0, n as u64, route_u2c.as_deref(), Some(&*ip_u2c));
la2.store(start.elapsed().as_millis() as u64, Ordering::Relaxed); la2.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
if let Some((ref ca, ca_start)) = conn_act_u2c { if let Some((ref ca, ca_start)) = conn_act_u2c {
ca.store(ca_start.elapsed().as_millis() as u64, Ordering::Relaxed); ca.store(ca_start.elapsed().as_millis() as u64, Ordering::Relaxed);
@@ -2227,13 +2245,13 @@ impl HttpProxyService {
} }
/// Serve a static file from the configured directory. /// Serve a static file from the configured directory.
fn serve_static_file( async fn serve_static_file(
path: &str, path: &str,
config: &rustproxy_config::RouteStaticFiles, config: &rustproxy_config::RouteStaticFiles,
) -> Response<BoxBody<Bytes, hyper::Error>> { ) -> Response<BoxBody<Bytes, hyper::Error>> {
use std::path::Path; use std::path::PathBuf;
let root = Path::new(&config.root); let root = PathBuf::from(&config.root);
// Sanitize path to prevent directory traversal // Sanitize path to prevent directory traversal
let clean_path = path.trim_start_matches('/'); let clean_path = path.trim_start_matches('/');
@@ -2242,7 +2260,12 @@ impl HttpProxyService {
let mut file_path = root.join(&clean_path); let mut file_path = root.join(&clean_path);
// If path points to a directory, try index files // If path points to a directory, try index files
if file_path.is_dir() || clean_path.is_empty() { let is_dir = if clean_path.is_empty() {
true
} else {
tokio::fs::metadata(&file_path).await.map(|m| m.is_dir()).unwrap_or(false)
};
if is_dir {
let index_files = config.index_files.as_deref() let index_files = config.index_files.as_deref()
.or(config.index.as_deref()) .or(config.index.as_deref())
.unwrap_or(&[]); .unwrap_or(&[]);
@@ -2256,7 +2279,7 @@ impl HttpProxyService {
} else { } else {
file_path.join(index) file_path.join(index)
}; };
if candidate.is_file() { if tokio::fs::metadata(&candidate).await.map(|m| m.is_file()).unwrap_or(false) {
file_path = candidate; file_path = candidate;
found = true; found = true;
break; break;
@@ -2268,11 +2291,11 @@ impl HttpProxyService {
} }
// Ensure the resolved path is within the root (prevent traversal) // Ensure the resolved path is within the root (prevent traversal)
let canonical_root = match root.canonicalize() { let canonical_root = match tokio::fs::canonicalize(&root).await {
Ok(p) => p, Ok(p) => p,
Err(_) => return error_response(StatusCode::NOT_FOUND, "Not found"), Err(_) => return error_response(StatusCode::NOT_FOUND, "Not found"),
}; };
let canonical_file = match file_path.canonicalize() { let canonical_file = match tokio::fs::canonicalize(&file_path).await {
Ok(p) => p, Ok(p) => p,
Err(_) => return error_response(StatusCode::NOT_FOUND, "Not found"), Err(_) => return error_response(StatusCode::NOT_FOUND, "Not found"),
}; };
@@ -2286,7 +2309,7 @@ impl HttpProxyService {
} }
// Read the file // Read the file
match std::fs::read(&file_path) { match tokio::fs::read(&file_path).await {
Ok(content) => { Ok(content) => {
let content_type = guess_content_type(&file_path); let content_type = guess_content_type(&file_path);
let mut response = Response::builder() let mut response = Response::builder()

View File

@@ -259,40 +259,49 @@ impl MetricsCollector {
/// Called per-chunk in the TCP copy loop. Only touches AtomicU64 counters — /// Called per-chunk in the TCP copy loop. Only touches AtomicU64 counters —
/// no Mutex is taken. The throughput trackers are fed during `sample_all()`. /// no Mutex is taken. The throughput trackers are fed during `sample_all()`.
pub fn record_bytes(&self, bytes_in: u64, bytes_out: u64, route_id: Option<&str>, source_ip: Option<&str>) { pub fn record_bytes(&self, bytes_in: u64, bytes_out: u64, route_id: Option<&str>, source_ip: Option<&str>) {
self.total_bytes_in.fetch_add(bytes_in, Ordering::Relaxed); // Short-circuit: only touch counters for the direction that has data.
self.total_bytes_out.fetch_add(bytes_out, Ordering::Relaxed); // CountingBody always calls with one direction zero — skipping the zero
// direction avoids ~50% of DashMap shard-locked reads per call.
// Accumulate into lock-free pending throughput counters if bytes_in > 0 {
self.global_pending_tp_in.fetch_add(bytes_in, Ordering::Relaxed); self.total_bytes_in.fetch_add(bytes_in, Ordering::Relaxed);
self.global_pending_tp_out.fetch_add(bytes_out, Ordering::Relaxed); self.global_pending_tp_in.fetch_add(bytes_in, Ordering::Relaxed);
}
if bytes_out > 0 {
self.total_bytes_out.fetch_add(bytes_out, Ordering::Relaxed);
self.global_pending_tp_out.fetch_add(bytes_out, Ordering::Relaxed);
}
// Per-route tracking: use get() first (zero-alloc fast path for existing entries), // Per-route tracking: use get() first (zero-alloc fast path for existing entries),
// fall back to entry() with to_string() only on the rare first-chunk miss. // fall back to entry() with to_string() only on the rare first-chunk miss.
if let Some(route_id) = route_id { if let Some(route_id) = route_id {
if let Some(counter) = self.route_bytes_in.get(route_id) { if bytes_in > 0 {
counter.fetch_add(bytes_in, Ordering::Relaxed); if let Some(counter) = self.route_bytes_in.get(route_id) {
} else { counter.fetch_add(bytes_in, Ordering::Relaxed);
self.route_bytes_in.entry(route_id.to_string()) } else {
.or_insert_with(|| AtomicU64::new(0)) self.route_bytes_in.entry(route_id.to_string())
.fetch_add(bytes_in, Ordering::Relaxed); .or_insert_with(|| AtomicU64::new(0))
.fetch_add(bytes_in, Ordering::Relaxed);
}
} }
if let Some(counter) = self.route_bytes_out.get(route_id) { if bytes_out > 0 {
counter.fetch_add(bytes_out, Ordering::Relaxed); if let Some(counter) = self.route_bytes_out.get(route_id) {
} else { counter.fetch_add(bytes_out, Ordering::Relaxed);
self.route_bytes_out.entry(route_id.to_string()) } else {
.or_insert_with(|| AtomicU64::new(0)) self.route_bytes_out.entry(route_id.to_string())
.fetch_add(bytes_out, Ordering::Relaxed); .or_insert_with(|| AtomicU64::new(0))
.fetch_add(bytes_out, Ordering::Relaxed);
}
} }
// Accumulate into per-route pending throughput counters (lock-free) // Accumulate into per-route pending throughput counters (lock-free)
if let Some(entry) = self.route_pending_tp.get(route_id) { if let Some(entry) = self.route_pending_tp.get(route_id) {
entry.0.fetch_add(bytes_in, Ordering::Relaxed); if bytes_in > 0 { entry.0.fetch_add(bytes_in, Ordering::Relaxed); }
entry.1.fetch_add(bytes_out, Ordering::Relaxed); if bytes_out > 0 { entry.1.fetch_add(bytes_out, Ordering::Relaxed); }
} else { } else {
let entry = self.route_pending_tp.entry(route_id.to_string()) let entry = self.route_pending_tp.entry(route_id.to_string())
.or_insert_with(|| (AtomicU64::new(0), AtomicU64::new(0))); .or_insert_with(|| (AtomicU64::new(0), AtomicU64::new(0)));
entry.0.fetch_add(bytes_in, Ordering::Relaxed); if bytes_in > 0 { entry.0.fetch_add(bytes_in, Ordering::Relaxed); }
entry.1.fetch_add(bytes_out, Ordering::Relaxed); if bytes_out > 0 { entry.1.fetch_add(bytes_out, Ordering::Relaxed); }
} }
} }
@@ -302,30 +311,34 @@ impl MetricsCollector {
// This prevents orphaned entries when record_bytes races with // This prevents orphaned entries when record_bytes races with
// connection_closed (which evicts all per-IP data on last close). // connection_closed (which evicts all per-IP data on last close).
if self.ip_connections.contains_key(ip) { if self.ip_connections.contains_key(ip) {
if let Some(counter) = self.ip_bytes_in.get(ip) { if bytes_in > 0 {
counter.fetch_add(bytes_in, Ordering::Relaxed); if let Some(counter) = self.ip_bytes_in.get(ip) {
} else { counter.fetch_add(bytes_in, Ordering::Relaxed);
self.ip_bytes_in.entry(ip.to_string()) } else {
.or_insert_with(|| AtomicU64::new(0)) self.ip_bytes_in.entry(ip.to_string())
.fetch_add(bytes_in, Ordering::Relaxed); .or_insert_with(|| AtomicU64::new(0))
.fetch_add(bytes_in, Ordering::Relaxed);
}
} }
if let Some(counter) = self.ip_bytes_out.get(ip) { if bytes_out > 0 {
counter.fetch_add(bytes_out, Ordering::Relaxed); if let Some(counter) = self.ip_bytes_out.get(ip) {
} else { counter.fetch_add(bytes_out, Ordering::Relaxed);
self.ip_bytes_out.entry(ip.to_string()) } else {
.or_insert_with(|| AtomicU64::new(0)) self.ip_bytes_out.entry(ip.to_string())
.fetch_add(bytes_out, Ordering::Relaxed); .or_insert_with(|| AtomicU64::new(0))
.fetch_add(bytes_out, Ordering::Relaxed);
}
} }
// Accumulate into per-IP pending throughput counters (lock-free) // Accumulate into per-IP pending throughput counters (lock-free)
if let Some(entry) = self.ip_pending_tp.get(ip) { if let Some(entry) = self.ip_pending_tp.get(ip) {
entry.0.fetch_add(bytes_in, Ordering::Relaxed); if bytes_in > 0 { entry.0.fetch_add(bytes_in, Ordering::Relaxed); }
entry.1.fetch_add(bytes_out, Ordering::Relaxed); if bytes_out > 0 { entry.1.fetch_add(bytes_out, Ordering::Relaxed); }
} else { } else {
let entry = self.ip_pending_tp.entry(ip.to_string()) let entry = self.ip_pending_tp.entry(ip.to_string())
.or_insert_with(|| (AtomicU64::new(0), AtomicU64::new(0))); .or_insert_with(|| (AtomicU64::new(0), AtomicU64::new(0)));
entry.0.fetch_add(bytes_in, Ordering::Relaxed); if bytes_in > 0 { entry.0.fetch_add(bytes_in, Ordering::Relaxed); }
entry.1.fetch_add(bytes_out, Ordering::Relaxed); if bytes_out > 0 { entry.1.fetch_add(bytes_out, Ordering::Relaxed); }
} }
} }
} }

View File

@@ -1,4 +1,4 @@
use std::net::SocketAddr; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -9,9 +9,11 @@ pub enum ProxyProtocolError {
UnsupportedVersion, UnsupportedVersion,
#[error("Parse error: {0}")] #[error("Parse error: {0}")]
Parse(String), Parse(String),
#[error("Incomplete header: need {0} bytes, got {1}")]
Incomplete(usize, usize),
} }
/// Parsed PROXY protocol v1 header. /// Parsed PROXY protocol header (v1 or v2).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ProxyProtocolHeader { pub struct ProxyProtocolHeader {
pub source_addr: SocketAddr, pub source_addr: SocketAddr,
@@ -24,14 +26,29 @@ pub struct ProxyProtocolHeader {
pub enum ProxyProtocol { pub enum ProxyProtocol {
Tcp4, Tcp4,
Tcp6, Tcp6,
Udp4,
Udp6,
Unknown, Unknown,
} }
/// Transport type for PROXY v2 header generation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProxyV2Transport {
Stream, // TCP
Datagram, // UDP
}
/// PROXY protocol v2 signature (12 bytes).
const PROXY_V2_SIGNATURE: [u8; 12] = [
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A,
];
// ===== v1 (text format) =====
/// Parse a PROXY protocol v1 header from data. /// Parse a PROXY protocol v1 header from data.
/// ///
/// Format: `PROXY TCP4 <src_ip> <dst_ip> <src_port> <dst_port>\r\n` /// Format: `PROXY TCP4 <src_ip> <dst_ip> <src_port> <dst_port>\r\n`
pub fn parse_v1(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtocolError> { pub fn parse_v1(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtocolError> {
// Find the end of the header line
let line_end = data let line_end = data
.windows(2) .windows(2)
.position(|w| w == b"\r\n") .position(|w| w == b"\r\n")
@@ -56,10 +73,10 @@ pub fn parse_v1(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc
_ => return Err(ProxyProtocolError::UnsupportedVersion), _ => return Err(ProxyProtocolError::UnsupportedVersion),
}; };
let src_ip: std::net::IpAddr = parts[2] let src_ip: IpAddr = parts[2]
.parse() .parse()
.map_err(|_| ProxyProtocolError::Parse("Invalid source IP".to_string()))?; .map_err(|_| ProxyProtocolError::Parse("Invalid source IP".to_string()))?;
let dst_ip: std::net::IpAddr = parts[3] let dst_ip: IpAddr = parts[3]
.parse() .parse()
.map_err(|_| ProxyProtocolError::Parse("Invalid destination IP".to_string()))?; .map_err(|_| ProxyProtocolError::Parse("Invalid destination IP".to_string()))?;
let src_port: u16 = parts[4] let src_port: u16 = parts[4]
@@ -75,7 +92,6 @@ pub fn parse_v1(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc
protocol, protocol,
}; };
// Consumed bytes = line + \r\n
Ok((header, line_end + 2)) Ok((header, line_end + 2))
} }
@@ -97,10 +113,219 @@ pub fn is_proxy_protocol_v1(data: &[u8]) -> bool {
data.starts_with(b"PROXY ") data.starts_with(b"PROXY ")
} }
// ===== v2 (binary format) =====
/// Check if data starts with a PROXY protocol v2 header.
pub fn is_proxy_protocol_v2(data: &[u8]) -> bool {
data.len() >= 12 && data[..12] == PROXY_V2_SIGNATURE
}
/// Parse a PROXY protocol v2 binary header.
///
/// Binary format:
/// - [0..12] signature (12 bytes)
/// - [12] version (high nibble) + command (low nibble)
/// - [13] address family (high nibble) + transport (low nibble)
/// - [14..16] address block length (big-endian u16)
/// - [16..] address block (variable, depends on family)
pub fn parse_v2(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtocolError> {
if data.len() < 16 {
return Err(ProxyProtocolError::Incomplete(16, data.len()));
}
// Validate signature
if data[..12] != PROXY_V2_SIGNATURE {
return Err(ProxyProtocolError::InvalidHeader);
}
// Version (high nibble of byte 12) must be 0x2
let version = (data[12] >> 4) & 0x0F;
if version != 2 {
return Err(ProxyProtocolError::UnsupportedVersion);
}
// Command (low nibble of byte 12)
let command = data[12] & 0x0F;
// 0x0 = LOCAL, 0x1 = PROXY
if command > 1 {
return Err(ProxyProtocolError::Parse(format!("Unknown command: {}", command)));
}
// Address family (high nibble) + transport (low nibble) of byte 13
let family = (data[13] >> 4) & 0x0F;
let transport = data[13] & 0x0F;
// Address block length
let addr_len = u16::from_be_bytes([data[14], data[15]]) as usize;
let total_len = 16 + addr_len;
if data.len() < total_len {
return Err(ProxyProtocolError::Incomplete(total_len, data.len()));
}
// LOCAL command: no real addresses, return unspecified
if command == 0 {
return Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
dest_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
protocol: ProxyProtocol::Unknown,
},
total_len,
));
}
// PROXY command: parse addresses based on family + transport
let addr_block = &data[16..16 + addr_len];
match (family, transport) {
// AF_INET (0x1) + STREAM (0x1) = TCP4
(0x1, 0x1) => {
if addr_len < 12 {
return Err(ProxyProtocolError::Parse("IPv4 address block too short".to_string()));
}
let src_ip = Ipv4Addr::new(addr_block[0], addr_block[1], addr_block[2], addr_block[3]);
let dst_ip = Ipv4Addr::new(addr_block[4], addr_block[5], addr_block[6], addr_block[7]);
let src_port = u16::from_be_bytes([addr_block[8], addr_block[9]]);
let dst_port = u16::from_be_bytes([addr_block[10], addr_block[11]]);
Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V4(src_ip), src_port),
dest_addr: SocketAddr::new(IpAddr::V4(dst_ip), dst_port),
protocol: ProxyProtocol::Tcp4,
},
total_len,
))
}
// AF_INET (0x1) + DGRAM (0x2) = UDP4
(0x1, 0x2) => {
if addr_len < 12 {
return Err(ProxyProtocolError::Parse("IPv4 address block too short".to_string()));
}
let src_ip = Ipv4Addr::new(addr_block[0], addr_block[1], addr_block[2], addr_block[3]);
let dst_ip = Ipv4Addr::new(addr_block[4], addr_block[5], addr_block[6], addr_block[7]);
let src_port = u16::from_be_bytes([addr_block[8], addr_block[9]]);
let dst_port = u16::from_be_bytes([addr_block[10], addr_block[11]]);
Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V4(src_ip), src_port),
dest_addr: SocketAddr::new(IpAddr::V4(dst_ip), dst_port),
protocol: ProxyProtocol::Udp4,
},
total_len,
))
}
// AF_INET6 (0x2) + STREAM (0x1) = TCP6
(0x2, 0x1) => {
if addr_len < 36 {
return Err(ProxyProtocolError::Parse("IPv6 address block too short".to_string()));
}
let src_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[0..16]).unwrap());
let dst_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[16..32]).unwrap());
let src_port = u16::from_be_bytes([addr_block[32], addr_block[33]]);
let dst_port = u16::from_be_bytes([addr_block[34], addr_block[35]]);
Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V6(src_ip), src_port),
dest_addr: SocketAddr::new(IpAddr::V6(dst_ip), dst_port),
protocol: ProxyProtocol::Tcp6,
},
total_len,
))
}
// AF_INET6 (0x2) + DGRAM (0x2) = UDP6
(0x2, 0x2) => {
if addr_len < 36 {
return Err(ProxyProtocolError::Parse("IPv6 address block too short".to_string()));
}
let src_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[0..16]).unwrap());
let dst_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[16..32]).unwrap());
let src_port = u16::from_be_bytes([addr_block[32], addr_block[33]]);
let dst_port = u16::from_be_bytes([addr_block[34], addr_block[35]]);
Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V6(src_ip), src_port),
dest_addr: SocketAddr::new(IpAddr::V6(dst_ip), dst_port),
protocol: ProxyProtocol::Udp6,
},
total_len,
))
}
// AF_UNSPEC or unknown
(0x0, _) => Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
dest_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
protocol: ProxyProtocol::Unknown,
},
total_len,
)),
_ => Err(ProxyProtocolError::Parse(format!(
"Unsupported family/transport: 0x{:X}{:X}",
family, transport
))),
}
}
/// Generate a PROXY protocol v2 binary header.
pub fn generate_v2(
source: &SocketAddr,
dest: &SocketAddr,
transport: ProxyV2Transport,
) -> Vec<u8> {
let transport_nibble: u8 = match transport {
ProxyV2Transport::Stream => 0x1,
ProxyV2Transport::Datagram => 0x2,
};
match (source.ip(), dest.ip()) {
(IpAddr::V4(src_ip), IpAddr::V4(dst_ip)) => {
let mut buf = Vec::with_capacity(28);
buf.extend_from_slice(&PROXY_V2_SIGNATURE);
buf.push(0x21); // version 2, PROXY command
buf.push(0x10 | transport_nibble); // AF_INET + transport
buf.extend_from_slice(&12u16.to_be_bytes()); // addr block length
buf.extend_from_slice(&src_ip.octets());
buf.extend_from_slice(&dst_ip.octets());
buf.extend_from_slice(&source.port().to_be_bytes());
buf.extend_from_slice(&dest.port().to_be_bytes());
buf
}
(IpAddr::V6(src_ip), IpAddr::V6(dst_ip)) => {
let mut buf = Vec::with_capacity(52);
buf.extend_from_slice(&PROXY_V2_SIGNATURE);
buf.push(0x21); // version 2, PROXY command
buf.push(0x20 | transport_nibble); // AF_INET6 + transport
buf.extend_from_slice(&36u16.to_be_bytes()); // addr block length
buf.extend_from_slice(&src_ip.octets());
buf.extend_from_slice(&dst_ip.octets());
buf.extend_from_slice(&source.port().to_be_bytes());
buf.extend_from_slice(&dest.port().to_be_bytes());
buf
}
// Mixed IPv4/IPv6: map IPv4 to IPv6-mapped address
_ => {
let src_v6 = match source.ip() {
IpAddr::V4(v4) => v4.to_ipv6_mapped(),
IpAddr::V6(v6) => v6,
};
let dst_v6 = match dest.ip() {
IpAddr::V4(v4) => v4.to_ipv6_mapped(),
IpAddr::V6(v6) => v6,
};
let src6 = SocketAddr::new(IpAddr::V6(src_v6), source.port());
let dst6 = SocketAddr::new(IpAddr::V6(dst_v6), dest.port());
generate_v2(&src6, &dst6, transport)
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
// ===== v1 tests =====
#[test] #[test]
fn test_parse_v1_tcp4() { fn test_parse_v1_tcp4() {
let header = b"PROXY TCP4 192.168.1.100 10.0.0.1 12345 443\r\n"; let header = b"PROXY TCP4 192.168.1.100 10.0.0.1 12345 443\r\n";
@@ -126,4 +351,130 @@ mod tests {
assert!(is_proxy_protocol_v1(b"PROXY TCP4 ...")); assert!(is_proxy_protocol_v1(b"PROXY TCP4 ..."));
assert!(!is_proxy_protocol_v1(b"GET / HTTP/1.1")); assert!(!is_proxy_protocol_v1(b"GET / HTTP/1.1"));
} }
// ===== v2 tests =====
#[test]
fn test_is_proxy_protocol_v2() {
assert!(is_proxy_protocol_v2(&PROXY_V2_SIGNATURE));
assert!(!is_proxy_protocol_v2(b"PROXY TCP4 ..."));
assert!(!is_proxy_protocol_v2(b"short"));
}
#[test]
fn test_parse_v2_tcp4() {
let source: SocketAddr = "198.51.100.10:54321".parse().unwrap();
let dest: SocketAddr = "203.0.113.25:8443".parse().unwrap();
let header = generate_v2(&source, &dest, ProxyV2Transport::Stream);
assert_eq!(header.len(), 28);
let (parsed, consumed) = parse_v2(&header).unwrap();
assert_eq!(consumed, 28);
assert_eq!(parsed.protocol, ProxyProtocol::Tcp4);
assert_eq!(parsed.source_addr, source);
assert_eq!(parsed.dest_addr, dest);
}
#[test]
fn test_parse_v2_udp4() {
let source: SocketAddr = "10.0.0.1:12345".parse().unwrap();
let dest: SocketAddr = "10.0.0.2:53".parse().unwrap();
let header = generate_v2(&source, &dest, ProxyV2Transport::Datagram);
assert_eq!(header.len(), 28);
assert_eq!(header[13], 0x12); // AF_INET + DGRAM
let (parsed, consumed) = parse_v2(&header).unwrap();
assert_eq!(consumed, 28);
assert_eq!(parsed.protocol, ProxyProtocol::Udp4);
assert_eq!(parsed.source_addr, source);
assert_eq!(parsed.dest_addr, dest);
}
#[test]
fn test_parse_v2_tcp6() {
let source: SocketAddr = "[2001:db8::1]:54321".parse().unwrap();
let dest: SocketAddr = "[2001:db8::2]:443".parse().unwrap();
let header = generate_v2(&source, &dest, ProxyV2Transport::Stream);
assert_eq!(header.len(), 52);
assert_eq!(header[13], 0x21); // AF_INET6 + STREAM
let (parsed, consumed) = parse_v2(&header).unwrap();
assert_eq!(consumed, 52);
assert_eq!(parsed.protocol, ProxyProtocol::Tcp6);
assert_eq!(parsed.source_addr, source);
assert_eq!(parsed.dest_addr, dest);
}
#[test]
fn test_generate_v2_tcp4_byte_layout() {
let source: SocketAddr = "1.2.3.4:1000".parse().unwrap();
let dest: SocketAddr = "5.6.7.8:443".parse().unwrap();
let header = generate_v2(&source, &dest, ProxyV2Transport::Stream);
assert_eq!(&header[0..12], &PROXY_V2_SIGNATURE);
assert_eq!(header[12], 0x21); // v2, PROXY
assert_eq!(header[13], 0x11); // AF_INET, STREAM
assert_eq!(u16::from_be_bytes([header[14], header[15]]), 12); // addr len
assert_eq!(&header[16..20], &[1, 2, 3, 4]); // src ip
assert_eq!(&header[20..24], &[5, 6, 7, 8]); // dst ip
assert_eq!(u16::from_be_bytes([header[24], header[25]]), 1000); // src port
assert_eq!(u16::from_be_bytes([header[26], header[27]]), 443); // dst port
}
#[test]
fn test_generate_v2_udp4_byte_layout() {
let source: SocketAddr = "10.0.0.1:5000".parse().unwrap();
let dest: SocketAddr = "10.0.0.2:53".parse().unwrap();
let header = generate_v2(&source, &dest, ProxyV2Transport::Datagram);
assert_eq!(header[12], 0x21); // v2, PROXY
assert_eq!(header[13], 0x12); // AF_INET, DGRAM (UDP)
}
#[test]
fn test_parse_v2_local_command() {
// Build a LOCAL command header (no addresses)
let mut header = Vec::new();
header.extend_from_slice(&PROXY_V2_SIGNATURE);
header.push(0x20); // v2, LOCAL
header.push(0x00); // AF_UNSPEC
header.extend_from_slice(&0u16.to_be_bytes()); // 0-length address block
let (parsed, consumed) = parse_v2(&header).unwrap();
assert_eq!(consumed, 16);
assert_eq!(parsed.protocol, ProxyProtocol::Unknown);
assert_eq!(parsed.source_addr.port(), 0);
}
#[test]
fn test_parse_v2_incomplete() {
let data = &PROXY_V2_SIGNATURE[..8]; // only 8 bytes
assert!(parse_v2(data).is_err());
}
#[test]
fn test_parse_v2_wrong_version() {
let mut header = Vec::new();
header.extend_from_slice(&PROXY_V2_SIGNATURE);
header.push(0x11); // version 1, not 2
header.push(0x11);
header.extend_from_slice(&12u16.to_be_bytes());
header.extend_from_slice(&[0u8; 12]);
assert!(matches!(parse_v2(&header), Err(ProxyProtocolError::UnsupportedVersion)));
}
#[test]
fn test_v2_roundtrip_with_trailing_data() {
let source: SocketAddr = "192.168.1.1:8080".parse().unwrap();
let dest: SocketAddr = "10.0.0.1:443".parse().unwrap();
let mut data = generate_v2(&source, &dest, ProxyV2Transport::Stream);
data.extend_from_slice(b"GET / HTTP/1.1\r\n"); // trailing app data
let (parsed, consumed) = parse_v2(&data).unwrap();
assert_eq!(consumed, 28);
assert_eq!(parsed.source_addr, source);
assert_eq!(&data[consumed..], b"GET / HTTP/1.1\r\n");
}
} }

View File

@@ -573,18 +573,30 @@ impl TcpListenerManager {
Err(_) => return Err("Initial data timeout (proxy protocol peek)".into()), Err(_) => return Err("Initial data timeout (proxy protocol peek)".into()),
}; };
if pn > 0 && crate::proxy_protocol::is_proxy_protocol_v1(&proxy_peek[..pn]) { if pn > 0 {
match crate::proxy_protocol::parse_v1(&proxy_peek[..pn]) { if crate::proxy_protocol::is_proxy_protocol_v1(&proxy_peek[..pn]) {
Ok((header, consumed)) => { match crate::proxy_protocol::parse_v1(&proxy_peek[..pn]) {
debug!("PROXY protocol: real client {} -> {}", header.source_addr, header.dest_addr); Ok((header, consumed)) => {
effective_peer_addr = header.source_addr; debug!("PROXY v1: real client {} -> {}", header.source_addr, header.dest_addr);
// Consume the proxy protocol header bytes (stack buffer, max 108 bytes) effective_peer_addr = header.source_addr;
let mut discard = [0u8; 128]; let mut discard = [0u8; 128];
stream.read_exact(&mut discard[..consumed]).await?; stream.read_exact(&mut discard[..consumed]).await?;
}
Err(e) => {
debug!("Failed to parse PROXY v1 header: {}", e);
}
} }
Err(e) => { } else if crate::proxy_protocol::is_proxy_protocol_v2(&proxy_peek[..pn]) {
debug!("Failed to parse PROXY protocol header: {}", e); match crate::proxy_protocol::parse_v2(&proxy_peek[..pn]) {
// Not a PROXY protocol header, continue normally Ok((header, consumed)) => {
debug!("PROXY v2: real client {} -> {} ({:?})", header.source_addr, header.dest_addr, header.protocol);
effective_peer_addr = header.source_addr;
let mut discard = [0u8; 256];
stream.read_exact(&mut discard[..consumed]).await?;
}
Err(e) => {
debug!("Failed to parse PROXY v2 header: {}", e);
}
} }
} }
} }

View File

@@ -1,133 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartproxy from '../ts/index.js';
import { ProxyProtocolParser } from '../ts/core/utils/proxy-protocol.js';
tap.test('PROXY protocol v1 parser - valid headers', async () => {
// Test TCP4 format
const tcp4Header = Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n', 'ascii');
const tcp4Result = ProxyProtocolParser.parse(tcp4Header);
expect(tcp4Result.proxyInfo).property('protocol').toEqual('TCP4');
expect(tcp4Result.proxyInfo).property('sourceIP').toEqual('192.168.1.1');
expect(tcp4Result.proxyInfo).property('sourcePort').toEqual(56324);
expect(tcp4Result.proxyInfo).property('destinationIP').toEqual('10.0.0.1');
expect(tcp4Result.proxyInfo).property('destinationPort').toEqual(443);
expect(tcp4Result.remainingData.length).toEqual(0);
// Test TCP6 format
const tcp6Header = Buffer.from('PROXY TCP6 2001:db8::1 2001:db8::2 56324 443\r\n', 'ascii');
const tcp6Result = ProxyProtocolParser.parse(tcp6Header);
expect(tcp6Result.proxyInfo).property('protocol').toEqual('TCP6');
expect(tcp6Result.proxyInfo).property('sourceIP').toEqual('2001:db8::1');
expect(tcp6Result.proxyInfo).property('sourcePort').toEqual(56324);
expect(tcp6Result.proxyInfo).property('destinationIP').toEqual('2001:db8::2');
expect(tcp6Result.proxyInfo).property('destinationPort').toEqual(443);
// Test UNKNOWN protocol
const unknownHeader = Buffer.from('PROXY UNKNOWN\r\n', 'ascii');
const unknownResult = ProxyProtocolParser.parse(unknownHeader);
expect(unknownResult.proxyInfo).property('protocol').toEqual('UNKNOWN');
expect(unknownResult.proxyInfo).property('sourceIP').toEqual('');
expect(unknownResult.proxyInfo).property('sourcePort').toEqual(0);
});
tap.test('PROXY protocol v1 parser - with remaining data', async () => {
const headerWithData = Buffer.concat([
Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n', 'ascii'),
Buffer.from('GET / HTTP/1.1\r\n', 'ascii')
]);
const result = ProxyProtocolParser.parse(headerWithData);
expect(result.proxyInfo).property('protocol').toEqual('TCP4');
expect(result.proxyInfo).property('sourceIP').toEqual('192.168.1.1');
expect(result.remainingData.toString()).toEqual('GET / HTTP/1.1\r\n');
});
tap.test('PROXY protocol v1 parser - invalid headers', async () => {
// Not a PROXY protocol header
const notProxy = Buffer.from('GET / HTTP/1.1\r\n', 'ascii');
const notProxyResult = ProxyProtocolParser.parse(notProxy);
expect(notProxyResult.proxyInfo).toBeNull();
expect(notProxyResult.remainingData).toEqual(notProxy);
// Invalid protocol
expect(() => {
ProxyProtocolParser.parse(Buffer.from('PROXY INVALID 1.1.1.1 2.2.2.2 80 443\r\n', 'ascii'));
}).toThrow();
// Wrong number of fields
expect(() => {
ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324\r\n', 'ascii'));
}).toThrow();
// Invalid port
expect(() => {
ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 99999 443\r\n', 'ascii'));
}).toThrow();
// Invalid IP for protocol
expect(() => {
ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 2001:db8::1 10.0.0.1 56324 443\r\n', 'ascii'));
}).toThrow();
});
tap.test('PROXY protocol v1 parser - incomplete headers', async () => {
// Header without terminator
const incomplete = Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443', 'ascii');
const result = ProxyProtocolParser.parse(incomplete);
expect(result.proxyInfo).toBeNull();
expect(result.remainingData).toEqual(incomplete);
// Header exceeding max length - create a buffer that actually starts with PROXY
const longHeader = Buffer.from('PROXY TCP4 ' + '1'.repeat(100), 'ascii');
expect(() => {
ProxyProtocolParser.parse(longHeader);
}).toThrow();
});
tap.test('PROXY protocol v1 generator', async () => {
// Generate TCP4 header
const tcp4Info = {
protocol: 'TCP4' as const,
sourceIP: '192.168.1.1',
sourcePort: 56324,
destinationIP: '10.0.0.1',
destinationPort: 443
};
const tcp4Header = ProxyProtocolParser.generate(tcp4Info);
expect(tcp4Header.toString('ascii')).toEqual('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n');
// Generate TCP6 header
const tcp6Info = {
protocol: 'TCP6' as const,
sourceIP: '2001:db8::1',
sourcePort: 56324,
destinationIP: '2001:db8::2',
destinationPort: 443
};
const tcp6Header = ProxyProtocolParser.generate(tcp6Info);
expect(tcp6Header.toString('ascii')).toEqual('PROXY TCP6 2001:db8::1 2001:db8::2 56324 443\r\n');
// Generate UNKNOWN header
const unknownInfo = {
protocol: 'UNKNOWN' as const,
sourceIP: '',
sourcePort: 0,
destinationIP: '',
destinationPort: 0
};
const unknownHeader = ProxyProtocolParser.generate(unknownInfo);
expect(unknownHeader.toString('ascii')).toEqual('PROXY UNKNOWN\r\n');
});
// Skipping integration tests for now - focus on unit tests
// Integration tests would require more complex setup and teardown
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', name: '@push.rocks/smartproxy',
version: '25.11.21', version: '25.12.0',
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
} }

View File

@@ -15,4 +15,3 @@ export * from './lifecycle-component.js';
export * from './binary-heap.js'; export * from './binary-heap.js';
export * from './enhanced-connection-pool.js'; export * from './enhanced-connection-pool.js';
export * from './socket-utils.js'; export * from './socket-utils.js';
export * from './proxy-protocol.js';

View File

@@ -1,129 +0,0 @@
import * as plugins from '../../plugins.js';
import { logger } from './logger.js';
import { ProxyProtocolParser as ProtocolParser, type IProxyInfo, type IProxyParseResult } from '../../protocols/proxy/index.js';
// Re-export types from protocols for backward compatibility
export type { IProxyInfo, IProxyParseResult } from '../../protocols/proxy/index.js';
/**
* Parser for PROXY protocol v1 (text format)
* Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
*
* This class now delegates to the protocol parser but adds
* smartproxy-specific features like socket reading and logging
*/
export class ProxyProtocolParser {
static readonly PROXY_V1_SIGNATURE = ProtocolParser.PROXY_V1_SIGNATURE;
static readonly MAX_HEADER_LENGTH = ProtocolParser.MAX_HEADER_LENGTH;
static readonly HEADER_TERMINATOR = ProtocolParser.HEADER_TERMINATOR;
/**
* Parse PROXY protocol v1 header from buffer
* Returns proxy info and remaining data after header
*/
static parse(data: Buffer): IProxyParseResult {
// Delegate to protocol parser
return ProtocolParser.parse(data);
}
/**
* Generate PROXY protocol v1 header
*/
static generate(info: IProxyInfo): Buffer {
// Delegate to protocol parser
return ProtocolParser.generate(info);
}
/**
* Validate IP address format
*/
private static isValidIP(ip: string, protocol: 'TCP4' | 'TCP6' | 'UNKNOWN'): boolean {
return ProtocolParser.isValidIP(ip, protocol);
}
/**
* Attempt to read a complete PROXY protocol header from a socket
* Returns null if no PROXY protocol detected or incomplete
*/
static async readFromSocket(socket: plugins.net.Socket, timeout: number = 5000): Promise<IProxyParseResult | null> {
return new Promise((resolve) => {
let buffer = Buffer.alloc(0);
let resolved = false;
const cleanup = () => {
socket.removeListener('data', onData);
socket.removeListener('error', onError);
clearTimeout(timer);
};
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
}
}, timeout);
const onData = (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
// Check if we have enough data
if (!buffer.toString('ascii', 0, Math.min(6, buffer.length)).startsWith(this.PROXY_V1_SIGNATURE)) {
// Not PROXY protocol
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
return;
}
// Try to parse
try {
const result = this.parse(buffer);
if (result.proxyInfo) {
// Successfully parsed
resolved = true;
cleanup();
resolve(result);
} else if (buffer.length > this.MAX_HEADER_LENGTH) {
// Header too long
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
}
// Otherwise continue reading
} catch (error) {
// Parse error
logger.log('error', `PROXY protocol parse error: ${error.message}`);
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
}
};
const onError = (error: Error) => {
logger.log('error', `Socket error while reading PROXY protocol: ${error.message}`);
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
};
socket.on('data', onData);
socket.on('error', onError);
});
}
}

View File

@@ -1,7 +1,6 @@
/** /**
* PROXY Protocol Module * PROXY Protocol Module
* HAProxy PROXY protocol implementation * Type definitions for HAProxy PROXY protocol v1/v2
*/ */
export * from './types.js'; export * from './types.js';
export * from './parser.js';

View File

@@ -1,183 +0,0 @@
/**
* PROXY Protocol Parser
* Implementation of HAProxy PROXY protocol v1 (text format)
* Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
*/
import type { IProxyInfo, IProxyParseResult, TProxyProtocol } from './types.js';
/**
* PROXY protocol parser
*/
export class ProxyProtocolParser {
static readonly PROXY_V1_SIGNATURE = 'PROXY ';
static readonly MAX_HEADER_LENGTH = 107; // Max length for v1 header
static readonly HEADER_TERMINATOR = '\r\n';
/**
* Parse PROXY protocol v1 header from buffer
* Returns proxy info and remaining data after header
*/
static parse(data: Buffer): IProxyParseResult {
// Check if buffer starts with PROXY signature
if (!data.toString('ascii', 0, 6).startsWith(this.PROXY_V1_SIGNATURE)) {
return {
proxyInfo: null,
remainingData: data
};
}
// Find header terminator
const headerEndIndex = data.indexOf(this.HEADER_TERMINATOR);
if (headerEndIndex === -1) {
// Header incomplete, need more data
if (data.length > this.MAX_HEADER_LENGTH) {
// Header too long, invalid
throw new Error('PROXY protocol header exceeds maximum length');
}
return {
proxyInfo: null,
remainingData: data
};
}
// Extract header line
const headerLine = data.toString('ascii', 0, headerEndIndex);
const remainingData = data.slice(headerEndIndex + 2); // Skip \r\n
// Parse header
const parts = headerLine.split(' ');
if (parts.length < 2) {
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
}
const [signature, protocol] = parts;
// Validate protocol
if (!['TCP4', 'TCP6', 'UNKNOWN'].includes(protocol)) {
throw new Error(`Invalid PROXY protocol: ${protocol}`);
}
// For UNKNOWN protocol, ignore addresses
if (protocol === 'UNKNOWN') {
return {
proxyInfo: {
protocol: 'UNKNOWN',
sourceIP: '',
sourcePort: 0,
destinationIP: '',
destinationPort: 0
},
remainingData
};
}
// For TCP4/TCP6, we need all 6 parts
if (parts.length !== 6) {
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
}
const [, , srcIP, dstIP, srcPort, dstPort] = parts;
// Validate and parse ports
const sourcePort = parseInt(srcPort, 10);
const destinationPort = parseInt(dstPort, 10);
if (isNaN(sourcePort) || sourcePort < 0 || sourcePort > 65535) {
throw new Error(`Invalid source port: ${srcPort}`);
}
if (isNaN(destinationPort) || destinationPort < 0 || destinationPort > 65535) {
throw new Error(`Invalid destination port: ${dstPort}`);
}
// Validate IP addresses
const protocolType = protocol as TProxyProtocol;
if (!this.isValidIP(srcIP, protocolType)) {
throw new Error(`Invalid source IP for ${protocol}: ${srcIP}`);
}
if (!this.isValidIP(dstIP, protocolType)) {
throw new Error(`Invalid destination IP for ${protocol}: ${dstIP}`);
}
return {
proxyInfo: {
protocol: protocolType,
sourceIP: srcIP,
sourcePort,
destinationIP: dstIP,
destinationPort
},
remainingData
};
}
/**
* Generate PROXY protocol v1 header
*/
static generate(info: IProxyInfo): Buffer {
if (info.protocol === 'UNKNOWN') {
return Buffer.from(`PROXY UNKNOWN\r\n`, 'ascii');
}
const header = `PROXY ${info.protocol} ${info.sourceIP} ${info.destinationIP} ${info.sourcePort} ${info.destinationPort}\r\n`;
if (header.length > this.MAX_HEADER_LENGTH) {
throw new Error('Generated PROXY protocol header exceeds maximum length');
}
return Buffer.from(header, 'ascii');
}
/**
* Validate IP address format
*/
static isValidIP(ip: string, protocol: TProxyProtocol): boolean {
if (protocol === 'TCP4') {
return this.isIPv4(ip);
} else if (protocol === 'TCP6') {
return this.isIPv6(ip);
}
return false;
}
/**
* Check if string is valid IPv4
*/
static isIPv4(ip: string): boolean {
const parts = ip.split('.');
if (parts.length !== 4) return false;
for (const part of parts) {
const num = parseInt(part, 10);
if (isNaN(num) || num < 0 || num > 255 || part !== num.toString()) {
return false;
}
}
return true;
}
/**
* Check if string is valid IPv6
*/
static isIPv6(ip: string): boolean {
// Basic IPv6 validation
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
return ipv6Regex.test(ip);
}
/**
* Create a connection ID string for tracking
*/
static createConnectionId(connectionInfo: {
sourceIp?: string;
sourcePort?: number;
destIp?: string;
destPort?: number;
}): string {
const { sourceIp, sourcePort, destIp, destPort } = connectionInfo;
return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`;
}
}

View File

@@ -11,7 +11,7 @@ export type TProxyProtocolVersion = 'v1' | 'v2';
/** /**
* Connection protocol type * Connection protocol type
*/ */
export type TProxyProtocol = 'TCP4' | 'TCP6' | 'UNKNOWN'; export type TProxyProtocol = 'TCP4' | 'TCP6' | 'UDP4' | 'UDP6' | 'UNKNOWN';
/** /**
* Interface representing parsed PROXY protocol information * Interface representing parsed PROXY protocol information