fix(rustproxy-http): preserve original Host header when proxying and add X-Forwarded-* headers; add TLS WebSocket echo backend helper and integration test for terminate-and-reencrypt websocket
This commit is contained in:
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-16 - 25.7.2 - fix(rustproxy-http)
|
||||||
|
preserve original Host header when proxying and add X-Forwarded-* headers; add TLS WebSocket echo backend helper and integration test for terminate-and-reencrypt websocket
|
||||||
|
|
||||||
|
- Preserve the client's original Host header instead of replacing it with backend host:port when proxying requests.
|
||||||
|
- Add standard reverse-proxy headers: X-Forwarded-For (appends client IP), X-Forwarded-Host, and X-Forwarded-Proto for upstream requests.
|
||||||
|
- Ensure raw TCP/HTTP upstream requests copy original headers and skip X-Forwarded-* (which are added explicitly).
|
||||||
|
- Add start_tls_ws_echo_backend test helper to start a TLS WebSocket echo backend for tests.
|
||||||
|
- Add integration test test_terminate_and_reencrypt_websocket to verify WS upgrade through terminate-and-reencrypt TLS path.
|
||||||
|
- Rename unused parameter upstream to _upstream in proxy_service functions to avoid warnings.
|
||||||
|
|
||||||
## 2026-02-16 - 25.7.1 - fix(proxy)
|
## 2026-02-16 - 25.7.1 - fix(proxy)
|
||||||
use TLS to backends for terminate-and-reencrypt routes
|
use TLS to backends for terminate-and-reencrypt routes
|
||||||
|
|
||||||
|
|||||||
@@ -404,6 +404,51 @@ impl HttpProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add standard reverse-proxy headers (X-Forwarded-*)
|
||||||
|
{
|
||||||
|
let original_host = parts.headers.get("host")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
let forwarded_proto = if route_match.route.action.tls.as_ref()
|
||||||
|
.map(|t| matches!(t.mode,
|
||||||
|
rustproxy_config::TlsMode::Terminate
|
||||||
|
| rustproxy_config::TlsMode::TerminateAndReencrypt))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
"https"
|
||||||
|
} else {
|
||||||
|
"http"
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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") {
|
||||||
|
format!("{}, {}", existing.to_str().unwrap_or(""), client_ip)
|
||||||
|
} else {
|
||||||
|
client_ip
|
||||||
|
};
|
||||||
|
if let Ok(val) = hyper::header::HeaderValue::from_str(&xff_value) {
|
||||||
|
upstream_headers.insert(
|
||||||
|
hyper::header::HeaderName::from_static("x-forwarded-for"),
|
||||||
|
val,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// X-Forwarded-Host: original Host header
|
||||||
|
if let Ok(val) = hyper::header::HeaderValue::from_str(original_host) {
|
||||||
|
upstream_headers.insert(
|
||||||
|
hyper::header::HeaderName::from_static("x-forwarded-host"),
|
||||||
|
val,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// X-Forwarded-Proto: original client protocol
|
||||||
|
if let Ok(val) = hyper::header::HeaderValue::from_str(forwarded_proto) {
|
||||||
|
upstream_headers.insert(
|
||||||
|
hyper::header::HeaderName::from_static("x-forwarded-proto"),
|
||||||
|
val,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to upstream with timeout (TLS if upstream.use_tls is set)
|
// Connect to upstream with timeout (TLS if upstream.use_tls is set)
|
||||||
let backend = if upstream.use_tls {
|
let backend = if upstream.use_tls {
|
||||||
match tokio::time::timeout(
|
match tokio::time::timeout(
|
||||||
@@ -469,7 +514,7 @@ impl HttpProxyService {
|
|||||||
body: Incoming,
|
body: Incoming,
|
||||||
upstream_headers: hyper::HeaderMap,
|
upstream_headers: hyper::HeaderMap,
|
||||||
upstream_path: &str,
|
upstream_path: &str,
|
||||||
upstream: &crate::upstream_selector::UpstreamSelection,
|
_upstream: &crate::upstream_selector::UpstreamSelection,
|
||||||
route: &rustproxy_config::RouteConfig,
|
route: &rustproxy_config::RouteConfig,
|
||||||
route_id: Option<&str>,
|
route_id: Option<&str>,
|
||||||
source_ip: &str,
|
source_ip: &str,
|
||||||
@@ -496,11 +541,6 @@ impl HttpProxyService {
|
|||||||
|
|
||||||
if let Some(headers) = upstream_req.headers_mut() {
|
if let Some(headers) = upstream_req.headers_mut() {
|
||||||
*headers = upstream_headers;
|
*headers = upstream_headers;
|
||||||
if let Ok(host_val) = hyper::header::HeaderValue::from_str(
|
|
||||||
&format!("{}:{}", upstream.host, upstream.port)
|
|
||||||
) {
|
|
||||||
headers.insert(hyper::header::HOST, host_val);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap the request body in CountingBody to track bytes_in
|
// Wrap the request body in CountingBody to track bytes_in
|
||||||
@@ -535,7 +575,7 @@ impl HttpProxyService {
|
|||||||
body: Incoming,
|
body: Incoming,
|
||||||
upstream_headers: hyper::HeaderMap,
|
upstream_headers: hyper::HeaderMap,
|
||||||
upstream_path: &str,
|
upstream_path: &str,
|
||||||
upstream: &crate::upstream_selector::UpstreamSelection,
|
_upstream: &crate::upstream_selector::UpstreamSelection,
|
||||||
route: &rustproxy_config::RouteConfig,
|
route: &rustproxy_config::RouteConfig,
|
||||||
route_id: Option<&str>,
|
route_id: Option<&str>,
|
||||||
source_ip: &str,
|
source_ip: &str,
|
||||||
@@ -562,11 +602,6 @@ impl HttpProxyService {
|
|||||||
|
|
||||||
if let Some(headers) = upstream_req.headers_mut() {
|
if let Some(headers) = upstream_req.headers_mut() {
|
||||||
*headers = upstream_headers;
|
*headers = upstream_headers;
|
||||||
if let Ok(host_val) = hyper::header::HeaderValue::from_str(
|
|
||||||
&format!("{}:{}", upstream.host, upstream.port)
|
|
||||||
) {
|
|
||||||
headers.insert(hyper::header::HOST, host_val);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap the request body in CountingBody to track bytes_in
|
// Wrap the request body in CountingBody to track bytes_in
|
||||||
@@ -739,13 +774,44 @@ impl HttpProxyService {
|
|||||||
parts.method, upstream_path
|
parts.method, upstream_path
|
||||||
);
|
);
|
||||||
|
|
||||||
let upstream_host = format!("{}:{}", upstream.host, upstream.port);
|
// Copy all original headers (preserving the client's Host header).
|
||||||
|
// Skip X-Forwarded-* since we set them ourselves below.
|
||||||
for (name, value) in parts.headers.iter() {
|
for (name, value) in parts.headers.iter() {
|
||||||
if name == hyper::header::HOST {
|
let name_str = name.as_str();
|
||||||
raw_request.push_str(&format!("host: {}\r\n", upstream_host));
|
if name_str == "x-forwarded-for"
|
||||||
} else {
|
|| name_str == "x-forwarded-host"
|
||||||
raw_request.push_str(&format!("{}: {}\r\n", name, value.to_str().unwrap_or("")));
|
|| name_str == "x-forwarded-proto"
|
||||||
|
{
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
raw_request.push_str(&format!("{}: {}\r\n", name, value.to_str().unwrap_or("")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add standard reverse-proxy headers (X-Forwarded-*)
|
||||||
|
{
|
||||||
|
let original_host = parts.headers.get("host")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
let forwarded_proto = if route.action.tls.as_ref()
|
||||||
|
.map(|t| matches!(t.mode,
|
||||||
|
rustproxy_config::TlsMode::Terminate
|
||||||
|
| rustproxy_config::TlsMode::TerminateAndReencrypt))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
"https"
|
||||||
|
} else {
|
||||||
|
"http"
|
||||||
|
};
|
||||||
|
|
||||||
|
let client_ip = peer_addr.ip().to_string();
|
||||||
|
let xff_value = if let Some(existing) = parts.headers.get("x-forwarded-for") {
|
||||||
|
format!("{}, {}", existing.to_str().unwrap_or(""), client_ip)
|
||||||
|
} else {
|
||||||
|
client_ip
|
||||||
|
};
|
||||||
|
raw_request.push_str(&format!("x-forwarded-for: {}\r\n", xff_value));
|
||||||
|
raw_request.push_str(&format!("x-forwarded-host: {}\r\n", original_host));
|
||||||
|
raw_request.push_str(&format!("x-forwarded-proto: {}\r\n", forwarded_proto));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref route_headers) = route.headers {
|
if let Some(ref route_headers) = route.headers {
|
||||||
|
|||||||
@@ -452,6 +452,86 @@ pub fn make_tls_terminate_route(
|
|||||||
route
|
route
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a TLS WebSocket echo backend: accepts TLS, performs WS handshake, then echoes data.
|
||||||
|
/// Combines TLS acceptance (like `start_tls_http_backend`) with WebSocket echo (like `start_ws_echo_backend`).
|
||||||
|
pub async fn start_tls_ws_echo_backend(
|
||||||
|
port: u16,
|
||||||
|
cert_pem: &str,
|
||||||
|
key_pem: &str,
|
||||||
|
) -> JoinHandle<()> {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let acceptor = rustproxy_passthrough::build_tls_acceptor(cert_pem, key_pem)
|
||||||
|
.expect("Failed to build TLS acceptor");
|
||||||
|
let acceptor = Arc::new(acceptor);
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(format!("127.0.0.1:{}", port))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("Failed to bind TLS WS echo backend on port {}", port));
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let (stream, _) = match listener.accept().await {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
let acc = acceptor.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tls_stream = match acc.accept(stream).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read the HTTP upgrade request
|
||||||
|
let mut buf = vec![0u8; 4096];
|
||||||
|
let n = match tls_stream.read(&mut buf).await {
|
||||||
|
Ok(0) | Err(_) => return,
|
||||||
|
Ok(n) => n,
|
||||||
|
};
|
||||||
|
|
||||||
|
let req_str = String::from_utf8_lossy(&buf[..n]);
|
||||||
|
|
||||||
|
// Extract Sec-WebSocket-Key for handshake
|
||||||
|
let ws_key = req_str
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.to_lowercase().starts_with("sec-websocket-key:"))
|
||||||
|
.map(|l| l.split(':').nth(1).unwrap_or("").trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Send 101 Switching Protocols
|
||||||
|
let accept_response = format!(
|
||||||
|
"HTTP/1.1 101 Switching Protocols\r\n\
|
||||||
|
Upgrade: websocket\r\n\
|
||||||
|
Connection: Upgrade\r\n\
|
||||||
|
Sec-WebSocket-Accept: {}\r\n\
|
||||||
|
\r\n",
|
||||||
|
ws_key
|
||||||
|
);
|
||||||
|
|
||||||
|
if tls_stream
|
||||||
|
.write_all(accept_response.as_bytes())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Echo all data back (raw TCP after upgrade)
|
||||||
|
let mut echo_buf = vec![0u8; 65536];
|
||||||
|
loop {
|
||||||
|
let n = match tls_stream.read(&mut echo_buf).await {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => n,
|
||||||
|
};
|
||||||
|
if tls_stream.write_all(&echo_buf[..n]).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper to create a TLS passthrough route for testing.
|
/// Helper to create a TLS passthrough route for testing.
|
||||||
pub fn make_tls_passthrough_route(
|
pub fn make_tls_passthrough_route(
|
||||||
port: u16,
|
port: u16,
|
||||||
|
|||||||
@@ -490,6 +490,12 @@ async fn test_terminate_and_reencrypt_http_routing() {
|
|||||||
"Expected /api/data path, got: {}",
|
"Expected /api/data path, got: {}",
|
||||||
alpha_body
|
alpha_body
|
||||||
);
|
);
|
||||||
|
// Verify original Host header is preserved (not replaced with backend IP:port)
|
||||||
|
assert!(
|
||||||
|
alpha_body.contains(r#""host":"alpha.example.com"#),
|
||||||
|
"Expected original Host header alpha.example.com, got: {}",
|
||||||
|
alpha_body
|
||||||
|
);
|
||||||
|
|
||||||
// Test beta domain - different host goes to different backend
|
// Test beta domain - different host goes to different backend
|
||||||
let beta_result = with_timeout(async {
|
let beta_result = with_timeout(async {
|
||||||
@@ -527,10 +533,136 @@ async fn test_terminate_and_reencrypt_http_routing() {
|
|||||||
"Expected /other path, got: {}",
|
"Expected /other path, got: {}",
|
||||||
beta_body
|
beta_body
|
||||||
);
|
);
|
||||||
|
// Verify original Host header is preserved for beta too
|
||||||
|
assert!(
|
||||||
|
beta_body.contains(r#""host":"beta.example.com"#),
|
||||||
|
"Expected original Host header beta.example.com, got: {}",
|
||||||
|
beta_body
|
||||||
|
);
|
||||||
|
|
||||||
proxy.stop().await.unwrap();
|
proxy.stop().await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test that WebSocket upgrade works through terminate-and-reencrypt mode.
|
||||||
|
///
|
||||||
|
/// Verifies the full chain: client→TLS→proxy terminates→re-encrypts→TLS→backend WebSocket.
|
||||||
|
/// The proxy's `handle_websocket_upgrade` checks `upstream.use_tls` and calls
|
||||||
|
/// `connect_tls_backend()` when true. This test covers that path.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_terminate_and_reencrypt_websocket() {
|
||||||
|
let backend_port = next_port();
|
||||||
|
let proxy_port = next_port();
|
||||||
|
let domain = "ws.example.com";
|
||||||
|
|
||||||
|
// Frontend cert (client→proxy TLS)
|
||||||
|
let (frontend_cert, frontend_key) = generate_self_signed_cert(domain);
|
||||||
|
// Backend cert (proxy→backend TLS)
|
||||||
|
let (backend_cert, backend_key) = generate_self_signed_cert("localhost");
|
||||||
|
|
||||||
|
// Start TLS WebSocket echo backend
|
||||||
|
let _backend = start_tls_ws_echo_backend(backend_port, &backend_cert, &backend_key).await;
|
||||||
|
|
||||||
|
// Create terminate-and-reencrypt route
|
||||||
|
let mut route = make_tls_terminate_route(
|
||||||
|
proxy_port,
|
||||||
|
domain,
|
||||||
|
"127.0.0.1",
|
||||||
|
backend_port,
|
||||||
|
&frontend_cert,
|
||||||
|
&frontend_key,
|
||||||
|
);
|
||||||
|
route.action.tls.as_mut().unwrap().mode = rustproxy_config::TlsMode::TerminateAndReencrypt;
|
||||||
|
|
||||||
|
let options = RustProxyOptions {
|
||||||
|
routes: vec![route],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut proxy = RustProxy::new(options).unwrap();
|
||||||
|
proxy.start().await.unwrap();
|
||||||
|
assert!(wait_for_port(proxy_port, 2000).await);
|
||||||
|
|
||||||
|
let result = with_timeout(
|
||||||
|
async {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
let tls_config = rustls::ClientConfig::builder()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(std::sync::Arc::new(InsecureVerifier))
|
||||||
|
.with_no_client_auth();
|
||||||
|
let connector =
|
||||||
|
tokio_rustls::TlsConnector::from(std::sync::Arc::new(tls_config));
|
||||||
|
|
||||||
|
let stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", proxy_port))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let server_name =
|
||||||
|
rustls::pki_types::ServerName::try_from(domain.to_string()).unwrap();
|
||||||
|
let mut tls_stream = connector.connect(server_name, stream).await.unwrap();
|
||||||
|
|
||||||
|
// Send WebSocket upgrade request through TLS
|
||||||
|
let request = format!(
|
||||||
|
"GET /ws HTTP/1.1\r\n\
|
||||||
|
Host: {}\r\n\
|
||||||
|
Upgrade: websocket\r\n\
|
||||||
|
Connection: Upgrade\r\n\
|
||||||
|
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
|
||||||
|
Sec-WebSocket-Version: 13\r\n\
|
||||||
|
\r\n",
|
||||||
|
domain
|
||||||
|
);
|
||||||
|
tls_stream.write_all(request.as_bytes()).await.unwrap();
|
||||||
|
|
||||||
|
// Read the 101 response (byte-by-byte until \r\n\r\n)
|
||||||
|
let mut response_buf = Vec::with_capacity(4096);
|
||||||
|
let mut temp = [0u8; 1];
|
||||||
|
loop {
|
||||||
|
let n = tls_stream.read(&mut temp).await.unwrap();
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
response_buf.push(temp[0]);
|
||||||
|
if response_buf.len() >= 4 {
|
||||||
|
let len = response_buf.len();
|
||||||
|
if response_buf[len - 4..] == *b"\r\n\r\n" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_str = String::from_utf8_lossy(&response_buf).to_string();
|
||||||
|
assert!(
|
||||||
|
response_str.contains("101"),
|
||||||
|
"Expected 101 Switching Protocols, got: {}",
|
||||||
|
response_str
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
response_str.to_lowercase().contains("upgrade: websocket"),
|
||||||
|
"Expected Upgrade header, got: {}",
|
||||||
|
response_str
|
||||||
|
);
|
||||||
|
|
||||||
|
// After upgrade, send data and verify echo
|
||||||
|
let test_data = b"Hello TLS WebSocket!";
|
||||||
|
tls_stream.write_all(test_data).await.unwrap();
|
||||||
|
|
||||||
|
// Read echoed data
|
||||||
|
let mut echo_buf = vec![0u8; 256];
|
||||||
|
let n = tls_stream.read(&mut echo_buf).await.unwrap();
|
||||||
|
let echoed = &echo_buf[..n];
|
||||||
|
|
||||||
|
assert_eq!(echoed, test_data, "Expected echo of sent data");
|
||||||
|
|
||||||
|
"ok".to_string()
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result, "ok");
|
||||||
|
proxy.stop().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// Test that the protocol field on route config is accepted and processed.
|
/// Test that the protocol field on route config is accepted and processed.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_protocol_field_in_route_config() {
|
async fn test_protocol_field_in_route_config() {
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '25.7.1',
|
version: '25.7.2',
|
||||||
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.'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user