Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e3c548012 | |||
| 1a2d7529db | |||
| 31514f54ae | |||
| 247653c9d0 |
13
changelog.md
13
changelog.md
@@ -1,5 +1,18 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-20 - 25.17.2 - fix(rustproxy-http)
|
||||||
|
enable TLS connections for HTTP/3 upstream requests when backend re-encryption or TLS is configured
|
||||||
|
|
||||||
|
- Pass backend TLS client configuration into the HTTP/3 request handler.
|
||||||
|
- Detect TLS-required upstream targets using route and target TLS settings before connecting.
|
||||||
|
- Build backend request URIs with the correct http or https scheme to match the upstream connection.
|
||||||
|
|
||||||
|
## 2026-03-20 - 25.17.1 - fix(rustproxy-routing)
|
||||||
|
allow QUIC UDP TLS connections without SNI to match domain-restricted routes
|
||||||
|
|
||||||
|
- Exempts UDP transport from the no-SNI rejection logic because QUIC encrypts the TLS ClientHello and SNI is unavailable at accept time
|
||||||
|
- Adds regression tests to confirm QUIC route matching succeeds without SNI while TCP TLS without SNI remains rejected
|
||||||
|
|
||||||
## 2026-03-19 - 25.17.0 - feat(rustproxy-passthrough)
|
## 2026-03-19 - 25.17.0 - feat(rustproxy-passthrough)
|
||||||
add PROXY protocol v2 client IP handling for UDP and QUIC listeners
|
add PROXY protocol v2 client IP handling for UDP and QUIC listeners
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "25.17.0",
|
"version": "25.17.2",
|
||||||
"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",
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ pub struct H3ProxyService {
|
|||||||
protocol_cache: Arc<ProtocolCache>,
|
protocol_cache: Arc<ProtocolCache>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
upstream_selector: UpstreamSelector,
|
upstream_selector: UpstreamSelector,
|
||||||
#[allow(dead_code)]
|
|
||||||
backend_tls_config: Arc<rustls::ClientConfig>,
|
backend_tls_config: Arc<rustls::ClientConfig>,
|
||||||
connect_timeout: Duration,
|
connect_timeout: Duration,
|
||||||
}
|
}
|
||||||
@@ -98,12 +97,14 @@ impl H3ProxyService {
|
|||||||
let rm = self.route_manager.load();
|
let rm = self.route_manager.load();
|
||||||
let pool = Arc::clone(&self.connection_pool);
|
let pool = Arc::clone(&self.connection_pool);
|
||||||
let metrics = Arc::clone(&self.metrics);
|
let metrics = Arc::clone(&self.metrics);
|
||||||
|
let backend_tls = Arc::clone(&self.backend_tls_config);
|
||||||
let connect_timeout = self.connect_timeout;
|
let connect_timeout = self.connect_timeout;
|
||||||
let client_ip = client_ip.clone();
|
let client_ip = client_ip.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = handle_h3_request(
|
if let Err(e) = handle_h3_request(
|
||||||
request, stream, port, &client_ip, &rm, &pool, &metrics, connect_timeout,
|
request, stream, port, &client_ip, &rm, &pool, &metrics,
|
||||||
|
&backend_tls, connect_timeout,
|
||||||
).await {
|
).await {
|
||||||
debug!("HTTP/3 request error from {}: {}", client_ip, e);
|
debug!("HTTP/3 request error from {}: {}", client_ip, e);
|
||||||
}
|
}
|
||||||
@@ -133,6 +134,7 @@ async fn handle_h3_request(
|
|||||||
route_manager: &RouteManager,
|
route_manager: &RouteManager,
|
||||||
_connection_pool: &ConnectionPool,
|
_connection_pool: &ConnectionPool,
|
||||||
metrics: &MetricsCollector,
|
metrics: &MetricsCollector,
|
||||||
|
backend_tls_config: &Arc<rustls::ClientConfig>,
|
||||||
connect_timeout: Duration,
|
connect_timeout: Duration,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let method = request.method().clone();
|
let method = request.method().clone();
|
||||||
@@ -173,7 +175,15 @@ async fn handle_h3_request(
|
|||||||
let backend_port = target.port.resolve(port);
|
let backend_port = target.port.resolve(port);
|
||||||
let backend_addr = format!("{}:{}", backend_host, backend_port);
|
let backend_addr = format!("{}:{}", backend_host, backend_port);
|
||||||
|
|
||||||
// Connect to backend via TCP HTTP/1.1 with timeout
|
// Determine if backend requires TLS (same logic as proxy_service.rs)
|
||||||
|
let mut use_tls = target.tls.is_some();
|
||||||
|
if let Some(ref tls) = route.action.tls {
|
||||||
|
if tls.mode == rustproxy_config::TlsMode::TerminateAndReencrypt {
|
||||||
|
use_tls = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to backend via TCP with timeout
|
||||||
let tcp_stream = tokio::time::timeout(
|
let tcp_stream = tokio::time::timeout(
|
||||||
connect_timeout,
|
connect_timeout,
|
||||||
tokio::net::TcpStream::connect(&backend_addr),
|
tokio::net::TcpStream::connect(&backend_addr),
|
||||||
@@ -183,15 +193,27 @@ async fn handle_h3_request(
|
|||||||
|
|
||||||
let _ = tcp_stream.set_nodelay(true);
|
let _ = tcp_stream.set_nodelay(true);
|
||||||
|
|
||||||
let io = hyper_util::rt::TokioIo::new(tcp_stream);
|
// Branch: wrap in TLS if backend requires it, then HTTP/1.1 handshake.
|
||||||
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await
|
// hyper's SendRequest<B> is NOT generic over the IO type, so both branches
|
||||||
.map_err(|e| anyhow::anyhow!("Backend handshake failed: {}", e))?;
|
// produce the same type and can be unified.
|
||||||
|
let mut sender = if use_tls {
|
||||||
tokio::spawn(async move {
|
let connector = tokio_rustls::TlsConnector::from(Arc::clone(backend_tls_config));
|
||||||
if let Err(e) = conn.await {
|
let server_name = rustls::pki_types::ServerName::try_from(backend_host.to_string())
|
||||||
debug!("Backend connection closed: {}", e);
|
.map_err(|e| anyhow::anyhow!("Invalid backend SNI '{}': {}", backend_host, e))?;
|
||||||
}
|
let tls_stream = connector.connect(server_name, tcp_stream).await
|
||||||
});
|
.map_err(|e| anyhow::anyhow!("Backend TLS handshake to {} failed: {}", backend_addr, e))?;
|
||||||
|
let io = hyper_util::rt::TokioIo::new(tls_stream);
|
||||||
|
let (sender, conn) = hyper::client::conn::http1::handshake(io).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Backend handshake failed: {}", e))?;
|
||||||
|
tokio::spawn(async move { let _ = conn.await; });
|
||||||
|
sender
|
||||||
|
} else {
|
||||||
|
let io = hyper_util::rt::TokioIo::new(tcp_stream);
|
||||||
|
let (sender, conn) = hyper::client::conn::http1::handshake(io).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Backend handshake failed: {}", e))?;
|
||||||
|
tokio::spawn(async move { let _ = conn.await; });
|
||||||
|
sender
|
||||||
|
};
|
||||||
|
|
||||||
// Stream request body from H3 client to backend via an mpsc channel.
|
// Stream request body from H3 client to backend via an mpsc channel.
|
||||||
// This avoids buffering the entire request body in memory.
|
// This avoids buffering the entire request body in memory.
|
||||||
@@ -214,7 +236,7 @@ async fn handle_h3_request(
|
|||||||
|
|
||||||
// Create a body that polls from the mpsc receiver
|
// Create a body that polls from the mpsc receiver
|
||||||
let body = H3RequestBody { receiver: body_rx };
|
let body = H3RequestBody { receiver: body_rx };
|
||||||
let backend_req = build_backend_request(&method, &backend_addr, &path, &host, &request, body)?;
|
let backend_req = build_backend_request(&method, &backend_addr, &path, &host, &request, body, use_tls)?;
|
||||||
|
|
||||||
let response = sender.send_request(backend_req).await
|
let response = sender.send_request(backend_req).await
|
||||||
.map_err(|e| anyhow::anyhow!("Backend request failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Backend request failed: {}", e))?;
|
||||||
@@ -294,10 +316,12 @@ fn build_backend_request<B>(
|
|||||||
host: &str,
|
host: &str,
|
||||||
original_request: &hyper::Request<()>,
|
original_request: &hyper::Request<()>,
|
||||||
body: B,
|
body: B,
|
||||||
|
use_tls: bool,
|
||||||
) -> anyhow::Result<hyper::Request<B>> {
|
) -> anyhow::Result<hyper::Request<B>> {
|
||||||
|
let scheme = if use_tls { "https" } else { "http" };
|
||||||
let mut req = hyper::Request::builder()
|
let mut req = hyper::Request::builder()
|
||||||
.method(method)
|
.method(method)
|
||||||
.uri(format!("http://{}{}", backend_addr, path))
|
.uri(format!("{}://{}{}", scheme, backend_addr, path))
|
||||||
.header("host", host);
|
.header("host", host);
|
||||||
|
|
||||||
// Forward non-pseudo headers
|
// Forward non-pseudo headers
|
||||||
|
|||||||
@@ -122,10 +122,16 @@ impl RouteManager {
|
|||||||
// This prevents session-ticket resumption from misrouting when clients
|
// This prevents session-ticket resumption from misrouting when clients
|
||||||
// omit SNI (RFC 8446 recommends but doesn't mandate SNI on resumption).
|
// omit SNI (RFC 8446 recommends but doesn't mandate SNI on resumption).
|
||||||
// Wildcard-only routes (domains: ["*"]) still match since they accept all.
|
// Wildcard-only routes (domains: ["*"]) still match since they accept all.
|
||||||
let patterns = domains.to_vec();
|
//
|
||||||
let is_wildcard_only = patterns.iter().all(|d| *d == "*");
|
// Exception: QUIC (UDP transport) encrypts the TLS ClientHello, so SNI
|
||||||
if !is_wildcard_only {
|
// is unavailable at accept time. Domain verification happens per-request
|
||||||
return false;
|
// in H3ProxyService via the :authority header.
|
||||||
|
if ctx.transport != Some(TransportProtocol::Udp) {
|
||||||
|
let patterns = domains.to_vec();
|
||||||
|
let is_wildcard_only = patterns.iter().all(|d| *d == "*");
|
||||||
|
if !is_wildcard_only {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -997,4 +1003,52 @@ mod tests {
|
|||||||
let result = manager.find_route(&udp_ctx).unwrap();
|
let result = manager.find_route(&udp_ctx).unwrap();
|
||||||
assert_eq!(result.route.name.as_deref(), Some("udp-route"));
|
assert_eq!(result.route.name.as_deref(), Some("udp-route"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quic_tls_no_sni_matches_domain_restricted_route() {
|
||||||
|
// QUIC accept-level matching: is_tls=true, domain=None, transport=Udp.
|
||||||
|
// Should match because QUIC encrypts the ClientHello — SNI is unavailable
|
||||||
|
// at accept time but verified per-request in H3ProxyService.
|
||||||
|
let mut route = make_route(443, Some("example.com"), 0);
|
||||||
|
route.route_match.transport = Some(TransportProtocol::Udp);
|
||||||
|
let routes = vec![route];
|
||||||
|
let manager = RouteManager::new(routes);
|
||||||
|
|
||||||
|
let ctx = MatchContext {
|
||||||
|
port: 443,
|
||||||
|
domain: None,
|
||||||
|
path: None,
|
||||||
|
client_ip: None,
|
||||||
|
tls_version: None,
|
||||||
|
headers: None,
|
||||||
|
is_tls: true,
|
||||||
|
protocol: Some("quic"),
|
||||||
|
transport: Some(TransportProtocol::Udp),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(manager.find_route(&ctx).is_some(),
|
||||||
|
"QUIC (UDP) with is_tls=true and domain=None should match domain-restricted routes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tcp_tls_no_sni_still_rejects_domain_restricted_route() {
|
||||||
|
// TCP TLS without SNI must still be rejected (no QUIC exemption).
|
||||||
|
let routes = vec![make_route(443, Some("example.com"), 0)];
|
||||||
|
let manager = RouteManager::new(routes);
|
||||||
|
|
||||||
|
let ctx = MatchContext {
|
||||||
|
port: 443,
|
||||||
|
domain: None,
|
||||||
|
path: None,
|
||||||
|
client_ip: None,
|
||||||
|
tls_version: None,
|
||||||
|
headers: None,
|
||||||
|
is_tls: true,
|
||||||
|
protocol: None,
|
||||||
|
transport: None, // TCP (default)
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(manager.find_route(&ctx).is_none(),
|
||||||
|
"TCP TLS without SNI should NOT match domain-restricted routes");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '25.17.0',
|
version: '25.17.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