Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 0 additions & 21 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ thiserror = "2"

# HTTP - our single-stack foundation
hyper = { version = "1", features = ["client", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["client-legacy", "client-proxy", "http1", "http2", "tokio"] }
hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"] }
http-body-util = "0.1"
bytes = "1"

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ Run with `-v` to see it happen: the debug log records both the cookies each hop

`proxy` routes a request through an HTTP or SOCKS5 proxy; `no_proxy` is a per-request list of hosts that bypass it and connect directly — the `NO_PROXY` equivalent. It's accepted by `request()`, `download()`, `raw_connect()`, and `BatchConfig`, and as the repeatable `--no-proxy` CLI flag.

An HTTPS request through a proxy is TLS with the target, carried inside the tunnel the proxy opens, so `verify_certs` and the other TLS settings apply to the target as usual. SOCKS5 credentials go in the URL (`socks5://user:pass@host:1080`). An `https://` proxy URL is refused for HTTPS targets, since that would need TLS inside TLS.

```python
# Proxy everything except internal hosts and the loopback range.
r = await client.request(
Expand Down
162 changes: 121 additions & 41 deletions src/client/hyper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,43 @@ struct OpenSslConnector {
// on the `RedirectHop` (or final `Response`).
peer_slot: PeerSlot,
connect_timeout: Duration,
// Set when requests go through a CONNECT or SOCKS5 proxy. The connector
// opens the tunnel itself, so that TLS is with the target, inside it.
proxy: Option<TunnelProxy>,
}

/// A proxy the connector tunnels through before talking to the target.
#[derive(Clone)]
struct TunnelProxy {
/// Dialed the same way a direct connection's target is.
uri: http::Uri,
kind: super::proxy::ProxyScheme,
username: Option<String>,
password: Option<String>,
}

impl TunnelProxy {
fn parse(proxy_url: &str, kind: super::proxy::ProxyScheme) -> Result<Self, ClientError> {
let proxy_uri: http::Uri = proxy_url.parse().map_err(|e: http::uri::InvalidUri| {
ClientError::invalid_url(format!("invalid proxy URL: {}", e))
})?;
// Credentials come from the URL's userinfo, `socks5://user:pass@host`.
let userinfo = proxy_uri
.authority()
.and_then(|a| a.as_str().rsplit_once('@'))
.map(|(u, _)| u.to_string());
let (username, password) = match userinfo.as_deref().map(|u| u.split_once(':')) {
Some(Some((user, pass))) => (Some(user.to_string()), Some(pass.to_string())),
Some(None) => (userinfo.clone(), None),
None => (None, None),
};
Ok(TunnelProxy {
uri: proxy_uri,
kind,
username,
password,
})
}
}

/// Encode a list of ALPN protocol names into the wire format OpenSSL
Expand Down Expand Up @@ -396,8 +433,14 @@ impl OpenSslConnector {
cert_slot,
peer_slot,
connect_timeout,
proxy: None,
})
}

fn with_proxy(mut self, proxy: TunnelProxy) -> Self {
self.proxy = Some(proxy);
self
}
}

// hyper-util's Client needs a Service<Uri> that returns an async connection.
Expand Down Expand Up @@ -499,19 +542,29 @@ impl tower_service::Service<http::Uri> for OpenSslConnector {
fn call(&mut self, uri: http::Uri) -> Self::Future {
let host = uri.host().unwrap_or("").to_string();
let is_https = uri.scheme_str() == Some("https");
let port = uri.port_u16().unwrap_or(if is_https { 443 } else { 80 });
let proxy = self.proxy.clone();
// Compute the slot key from the original URI before we hand it
// off to hyper's HttpConnector — that's the authority callers
// will look up by.
let slot_key = peer_slot_key(&uri);
let http_fut = self.http.call(uri);
// will look up by. Through a proxy the socket's peer is the proxy,
// not the target, so there is no peer IP to record.
let slot_key = if proxy.is_some() {
None
} else {
peer_slot_key(&uri)
};
let http_fut = match &proxy {
Some(p) => self.http.call(p.uri.clone()),
None => self.http.call(uri),
};
let ssl_connector = self.ssl.clone();
let cert_slot = self.cert_slot.clone();
let peer_slot = self.peer_slot.clone();
let connect_timeout = self.connect_timeout;

Box::pin(async move {
let tcp = http_fut.await?;
let tcp_stream = tcp.into_inner();
let mut tcp_stream = tcp.into_inner();

// Record peer IP for this fresh connection. Best-effort —
// failure to read peer_addr (vanishingly rare) is not fatal.
Expand All @@ -521,6 +574,45 @@ impl tower_service::Service<http::Uri> for OpenSslConnector {
map.insert(key, peer.ip());
}

// Open the tunnel to the target before anything else, so that
// the TLS below is with the target and runs inside it.
if let Some(p) = proxy {
use super::proxy::{self as tunnel, ProxyScheme};
let handshake = async {
match p.kind {
ProxyScheme::Http => {
tunnel::perform_http_connect(&mut tcp_stream, &host, port).await
}
ProxyScheme::Socks5 => {
tunnel::perform_socks5(
&mut tcp_stream,
&host,
port,
p.username.as_deref(),
p.password.as_deref(),
)
.await
}
}
};
tokio::time::timeout(connect_timeout, handshake)
.await
.map_err(|_| -> Box<dyn std::error::Error + Send + Sync> {
Box::new(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"proxy tunnel to {}:{} timed out after {}s",
host,
port,
connect_timeout.as_secs()
),
))
})?
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
Box::new(std::io::Error::other(e.message))
})?;
}

// Plain HTTP — return raw TCP stream, no TLS handshake
if !is_https {
return Ok(ConnectionStream::Plain(tcp_stream));
Expand Down Expand Up @@ -586,31 +678,25 @@ impl tower_service::Service<http::Uri> for OpenSslConnector {
// `GET http://target/path HTTP/1.1`. Uses raw http1::SendRequest to bypass
// hyper Client's URI normalization (which strips scheme+authority).
// - CONNECT tunnel (HTTPS targets): proxy opens a raw TCP tunnel via CONNECT.
// Uses hyper_util's Tunnel connector.
// - SOCKS5: works for both HTTP and HTTPS targets.
//
// For both tunnel kinds `OpenSslConnector` opens the tunnel itself and then
// does TLS with the target over it. hyper_util's `Tunnel` and `SocksV5` can't
// be used for this: they hand the inner connector the proxy's URI, so TLS
// would be decided by the proxy's scheme and HTTPS would go into the tunnel
// as plaintext.

type DirectClient = Client<OpenSslConnector, FullBody>;
type TunnelProxyClient =
Client<hyper_util::client::legacy::connect::proxy::Tunnel<OpenSslConnector>, FullBody>;
type Socks5ProxyClient =
Client<hyper_util::client::legacy::connect::proxy::SocksV5<OpenSslConnector>, FullBody>;
type PooledClient = Client<OpenSslConnector, FullBody>;

/// The cached hyper client + its cert info slot.
/// hyper's Client uses Arc internally, so Clone shares the connection pool.
#[derive(Clone)]
struct CachedClient {
inner: AnyClient,
inner: PooledClient,
cert_slot: CertSlot,
peer_slot: PeerSlot,
}

#[derive(Clone)]
enum AnyClient {
Direct(DirectClient),
Tunnel(TunnelProxyClient),
Socks5(Socks5ProxyClient),
}

/// TLS config fields that must be part of the client cache key.
/// Requests with different TLS settings must not share a cached client.
#[derive(Clone, Hash, Eq, PartialEq)]
Expand Down Expand Up @@ -682,6 +768,15 @@ impl HyperClient {
"http" | "https" => {
// HTTP proxy: use forward proxy for HTTP targets, tunnel for HTTPS
let target_is_https = target_uri.scheme_str() == Some("https");
if target_is_https && proxy_scheme == "https" {
// That would be TLS to the target inside TLS to
// the proxy, which isn't supported.
return Err(ClientError::other(format!(
"https:// proxies are not supported for HTTPS targets; \
use http:// or socks5:// ({})",
proxy_url
)));
}
if target_is_https {
Ok(ConnMode::Tunnel {
proxy_url: proxy_url.to_string(),
Expand Down Expand Up @@ -724,33 +819,23 @@ impl HyperClient {
let connector = OpenSslConnector::new(config, cert_slot.clone(), peer_slot.clone())?;
let builder = Client::builder(TokioExecutor::new());

let inner = match mode {
ConnMode::Direct(_) => AnyClient::Direct(builder.build(connector)),
use super::proxy::ProxyScheme;
let connector = match mode {
ConnMode::Direct(_) => connector,
ConnMode::ForwardProxy(_) => {
// Forward proxy doesn't use a cached hyper Client — it dispatches
// directly via http1::SendRequest in send_inner. This branch should
// never be reached.
unreachable!("ForwardProxy uses dispatch_forward_proxy, not get_or_build")
}
ConnMode::Tunnel { proxy_url, .. } => {
let proxy_uri: http::Uri =
proxy_url.parse().map_err(|e: http::uri::InvalidUri| {
ClientError::invalid_url(format!("invalid proxy URL: {}", e))
})?;
use hyper_util::client::legacy::connect::proxy::Tunnel;
let tunnel = Tunnel::new(proxy_uri, connector);
AnyClient::Tunnel(builder.build(tunnel))
connector.with_proxy(TunnelProxy::parse(proxy_url, ProxyScheme::Http)?)
}
ConnMode::Socks5 { proxy_url, .. } => {
let proxy_uri: http::Uri =
proxy_url.parse().map_err(|e: http::uri::InvalidUri| {
ClientError::invalid_url(format!("invalid proxy URL: {}", e))
})?;
use hyper_util::client::legacy::connect::proxy::SocksV5;
let socks = SocksV5::new(proxy_uri, connector);
AnyClient::Socks5(builder.build(socks))
connector.with_proxy(TunnelProxy::parse(proxy_url, ProxyScheme::Socks5)?)
}
};
let inner = builder.build(connector);

let cached = CachedClient {
inner,
Expand All @@ -765,7 +850,7 @@ impl HyperClient {
// ── Request dispatch ──────────────────────────────────────────────

async fn dispatch_request(
client: &AnyClient,
client: &PooledClient,
uri: &http::Uri,
config: &RequestConfig,
log: &DebugLog,
Expand All @@ -788,12 +873,7 @@ async fn dispatch_request(
}
debug_record(log, v, 1, " Sending request...");

let hyper_response = match client {
AnyClient::Direct(c) => c.request(request).await,
AnyClient::Tunnel(c) => c.request(request).await,
AnyClient::Socks5(c) => c.request(request).await,
}
.map_err(|e| {
let hyper_response = client.request(request).await.map_err(|e| {
let msg = format!("request failed: {}", e);
let err_str = e.to_string().to_lowercase();
if err_str.contains("ssl") || err_str.contains("tls") || err_str.contains("certificate") {
Expand Down
Loading
Loading