From 5b15f5fadf5faf380e6465db4b452401422feb97 Mon Sep 17 00:00:00 2001 From: at264939-ctrl Date: Sat, 20 Jun 2026 15:50:56 +0300 Subject: [PATCH] Feat: Add HTTP/HTTPS Proxy support to AWC (Fix #4008) --- awc/CHANGES.md | 1 + awc/src/builder.rs | 56 +++++++++- awc/src/connect.rs | 265 ++++++++++++++++++++++++++++++++++++--------- awc/src/lib.rs | 2 + awc/src/proxy.rs | 200 ++++++++++++++++++++++++++++++++++ 5 files changed, 470 insertions(+), 54 deletions(-) create mode 100644 awc/src/proxy.rs diff --git a/awc/CHANGES.md b/awc/CHANGES.md index 9888c66c3..2076b1391 100644 --- a/awc/CHANGES.md +++ b/awc/CHANGES.md @@ -2,6 +2,7 @@ ## Unreleased +- Add support for HTTP and HTTPS proxies in `awc::Client`. Proxies can be configured explicitly via `ClientBuilder::proxy()` or automatically detected from `HTTP_PROXY` and `HTTPS_PROXY` environment variables. - Add camel-case header controls to `WebsocketsRequest` via `camel_case_headers()` and `set_camel_case_headers()`. [#3953] - Update `hickory-resolver` dependency to `0.26.1`. - Update `rand` dependency to `0.10`. diff --git a/awc/src/builder.rs b/awc/src/builder.rs index 5aae394f8..a4681c401 100644 --- a/awc/src/builder.rs +++ b/awc/src/builder.rs @@ -16,6 +16,7 @@ use crate::{ connect::DefaultConnector, error::SendRequestError, middleware::{NestTransform, Redirect, Transform}, + proxy::Proxy, Client, ConnectRequest, ConnectResponse, }; @@ -34,6 +35,10 @@ pub struct ClientBuilder { middleware: M, local_address: Option, max_redirects: u8, + /// Explicit proxy configuration (overrides env-var detection when set). + proxy: Option, + /// When `true`, env-var proxy detection is disabled entirely. + disable_proxy: bool, } impl ClientBuilder { @@ -63,6 +68,8 @@ impl ClientBuilder { middleware: (), local_address: None, max_redirects: 10, + proxy: None, + disable_proxy: false, } } } @@ -93,6 +100,8 @@ where stream_window_size: self.stream_window_size, conn_window_size: self.conn_window_size, max_redirects: self.max_redirects, + proxy: self.proxy, + disable_proxy: self.disable_proxy, } } @@ -141,6 +150,42 @@ where self } + /// Route all outgoing connections through `proxy`. + /// + /// Calling this method disables automatic environment-variable detection + /// for the lifetime of the client. To opt out of both explicit and + /// automatic proxies, call [`ClientBuilder::no_proxy`] instead. + /// + /// # Example + /// ``` + /// use awc::{Client, proxy::Proxy}; + /// + /// let client = Client::builder() + /// .proxy(Proxy::new("http://proxy.corp.example:3128")) + /// .finish(); + /// ``` + pub fn proxy(mut self, proxy: Proxy) -> Self { + self.proxy = Some(proxy); + // Explicit proxy overrides env detection. + self.disable_proxy = true; + self + } + + /// Disable proxy support entirely, including automatic detection from the + /// `HTTP_PROXY` / `HTTPS_PROXY` environment variables. + /// + /// # Example + /// ``` + /// use awc::Client; + /// + /// let client = Client::builder().no_proxy().finish(); + /// ``` + pub fn no_proxy(mut self) -> Self { + self.proxy = None; + self.disable_proxy = true; + self + } + /// Indicates the initial window size (in octets) for /// HTTP2 stream-level flow control for received data. /// @@ -245,6 +290,8 @@ where connector: self.connector, local_address: self.local_address, max_redirects: self.max_redirects, + proxy: self.proxy, + disable_proxy: self.disable_proxy, } } @@ -284,7 +331,14 @@ where connector = connector.local_address(val); } - let connector = DefaultConnector::new(connector.finish()); + // Resolve the proxy: explicit takes priority, then env-var auto-detection. + let proxy = if self.disable_proxy { + self.proxy + } else { + self.proxy.or_else(Proxy::from_env) + }; + + let connector = DefaultConnector::new(connector.finish(), proxy); let connector = boxed::rc_service(self.middleware.new_transform(connector)); Client(ClientConfig { diff --git a/awc/src/connect.rs b/awc/src/connect.rs index 14ed9e958..99686df34 100644 --- a/awc/src/connect.rs +++ b/awc/src/connect.rs @@ -1,6 +1,6 @@ use std::{ future::Future, - net, + io, net, pin::Pin, rc::Rc, task::{Context, Poll}, @@ -9,11 +9,15 @@ use std::{ use actix_codec::Framed; use actix_http::{h1::ClientCodec, Payload, RequestHead, RequestHeadType, ResponseHead}; use actix_service::Service; +use bytes::BytesMut; use futures_core::{future::LocalBoxFuture, ready}; +use http::header::HeaderValue; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use crate::{ any_body::AnyBody, client::{Connect as ClientConnect, ConnectError, Connection, ConnectionIo, SendRequestError}, + proxy::Proxy, ClientResponse, }; @@ -82,11 +86,12 @@ impl ConnectResponse { pub struct DefaultConnector { connector: S, + proxy: Option, } impl DefaultConnector { - pub(crate) fn new(connector: S) -> Self { - Self { connector } + pub(crate) fn new(connector: S, proxy: Option) -> Self { + Self { connector, proxy } } } @@ -102,21 +107,63 @@ where actix_service::forward_ready!(connector); fn call(&self, req: ConnectRequest) -> Self::Future { - // connect to the host - let fut = match req { - ConnectRequest::Client(ref head, .., addr) => self.connector.call(ClientConnect { - uri: head.as_ref().uri.clone(), - addr, - }), - ConnectRequest::Tunnel(ref head, addr) => self.connector.call(ClientConnect { - uri: head.uri.clone(), - addr, - }), + // Determine the target URI and whether we need a CONNECT tunnel. + let (target_uri, need_tunnel, tunnel_host) = match &req { + ConnectRequest::Client(head, ..) => { + let uri = &head.as_ref().uri; + let is_https = uri.scheme_str() == Some("https"); + let host_port = format!( + "{}:{}", + uri.host().unwrap_or(""), + uri.port_u16().unwrap_or(if is_https { 443 } else { 80 }) + ); + (uri.clone(), is_https, host_port) + } + ConnectRequest::Tunnel(head, _) => { + let uri = &head.uri; + let is_https = uri.scheme_str() == Some("https"); + let host_port = format!( + "{}:{}", + uri.host().unwrap_or(""), + uri.port_u16().unwrap_or(if is_https { 443 } else { 80 }) + ); + (uri.clone(), is_https, host_port) + } }; + // When a proxy is configured: + // - HTTP -> connect to proxy, send the full request URI as-is (proxy forwards) + // - HTTPS -> connect to proxy, issue a CONNECT tunnel, then send request normally + let (connect_uri, proxy_auth, proxy_tunnel_host) = match &self.proxy { + Some(proxy) => { + let auth = proxy.auth_header.clone(); + if need_tunnel { + // HTTPS: connect to proxy first, then CONNECT-tunnel to target + (proxy.uri.clone(), auth, Some(tunnel_host)) + } else { + // HTTP: connect to proxy directly; the connector keeps the original + // request unchanged so the full URI goes in the request line. + (proxy.uri.clone(), auth, None) + } + } + None => (target_uri, None, None), + }; + + let addr = match &req { + ConnectRequest::Client(_, _, a) => *a, + ConnectRequest::Tunnel(_, a) => *a, + }; + + let fut = self.connector.call(ClientConnect { + uri: connect_uri, + addr, + }); + ConnectRequestFuture::Connection { fut, req: Some(req), + proxy_auth, + proxy_tunnel_host, } } } @@ -130,17 +177,20 @@ pin_project_lite::pin_project! { Connection { #[pin] fut: Fut, - req: Option - }, - Client { - fut: LocalBoxFuture<'static, Result<(ResponseHead, Payload), SendRequestError>> + req: Option, + proxy_auth: Option, + proxy_tunnel_host: Option, }, Tunnel { - fut: LocalBoxFuture< - 'static, - Result<(ResponseHead, Framed, ClientCodec>), SendRequestError>, - >, - } + fut: LocalBoxFuture<'static, Result<(ResponseHead, Framed, ClientCodec>), SendRequestError>>, + }, + Client { + fut: LocalBoxFuture<'static, Result<(ResponseHead, Payload), SendRequestError>>, + }, + ProxyConnect { + fut: LocalBoxFuture<'static, Result, SendRequestError>>, + req: Option, + }, } } @@ -152,46 +202,155 @@ where type Output = Result; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - match self.as_mut().project() { - ConnectRequestProj::Connection { fut, req } => { - let connection = ready!(fut.poll(cx))?; - let req = req.take().unwrap(); + loop { + match self.as_mut().project() { + ConnectRequestProj::Connection { + fut, + req, + proxy_auth, + proxy_tunnel_host, + } => { + let connection = ready!(fut.poll(cx))?; + let req = req.take().unwrap(); - match req { - ConnectRequest::Client(head, body, ..) => { - // send request - let fut = ConnectRequestFuture::Client { - fut: connection.send_request(head, body), - }; - - self.set(fut); + if let Some(host) = proxy_tunnel_host.take() { + // We connected to the proxy over TCP. Now negotiate a CONNECT + // tunnel so subsequent TLS/HTTP2 traffic goes to the real target. + let auth_hdr = proxy_auth.take(); + let fut = Box::pin(proxy_connect_tunnel(connection, host, auth_hdr)); + self.set(ConnectRequestFuture::ProxyConnect { + fut, + req: Some(req), + }); + // Loop back to poll the new state immediately. + continue; } - ConnectRequest::Tunnel(head, ..) => { - // send request - let fut = ConnectRequestFuture::Tunnel { - fut: connection.open_tunnel(RequestHeadType::from(head)), - }; - - self.set(fut); + match req { + ConnectRequest::Client(head, body, ..) => { + let fut = ConnectRequestFuture::Client { + fut: connection.send_request(head, body), + }; + self.set(fut); + } + ConnectRequest::Tunnel(head, ..) => { + let fut = ConnectRequestFuture::Tunnel { + fut: connection.open_tunnel(RequestHeadType::from(head)), + }; + self.set(fut); + } } + // Loop back to poll the newly set state. + continue; } - self.poll(cx) - } + ConnectRequestProj::ProxyConnect { fut, req } => { + let connection = ready!(fut.as_mut().poll(cx))?; + let req = req.take().unwrap(); - ConnectRequestProj::Client { fut } => { - let (head, payload) = ready!(fut.as_mut().poll(cx))?; - Poll::Ready(Ok(ConnectResponse::Client(ClientResponse::new( - head, payload, - )))) - } + match req { + ConnectRequest::Client(head, body, ..) => { + let inner_fut = ConnectRequestFuture::Client { + fut: connection.send_request(head, body), + }; + self.set(inner_fut); + } + ConnectRequest::Tunnel(head, ..) => { + let inner_fut = ConnectRequestFuture::Tunnel { + fut: connection.open_tunnel(RequestHeadType::from(head)), + }; + self.set(inner_fut); + } + } + continue; + } - ConnectRequestProj::Tunnel { fut } => { - let (head, framed) = ready!(fut.as_mut().poll(cx))?; - let framed = framed.into_map_io(|io| Box::new(io) as _); - Poll::Ready(Ok(ConnectResponse::Tunnel(head, framed))) + ConnectRequestProj::Client { fut } => { + let (head, payload) = ready!(fut.as_mut().poll(cx))?; + return Poll::Ready(Ok(ConnectResponse::Client(ClientResponse::new( + head, payload, + )))); + } + + ConnectRequestProj::Tunnel { fut } => { + let (head, framed) = ready!(fut.as_mut().poll(cx))?; + let framed = framed.into_map_io(|io| Box::new(io) as _); + return Poll::Ready(Ok(ConnectResponse::Tunnel(head, framed))); + } } } } } + +/// Negotiate an HTTP `CONNECT` tunnel with the proxy. +/// +/// Writes the `CONNECT host:port HTTP/1.1` preamble to `connection`, reads +/// the proxy's `200 Connection established` response, and returns the +/// connection (now a raw tunnel) or an error. +async fn proxy_connect_tunnel( + mut connection: Connection, + host: String, + auth: Option, +) -> Result, SendRequestError> { + // Build the CONNECT request bytes manually to avoid going through + // the full HTTP codec machinery (CONNECT is a very simple exchange). + let mut request = format!("CONNECT {host} HTTP/1.1\r\nHost: {host}\r\n"); + + if let Some(auth_hdr) = auth { + if let Ok(val) = auth_hdr.to_str() { + request.push_str(&format!("Proxy-Authorization: {val}\r\n")); + } + } + request.push_str("\r\n"); + + // `Connection` derefs to its inner `Io` type which implements + // `AsyncRead + AsyncWrite` via `ActixStream`/`ConnectionIo`. + connection + .write_all(request.as_bytes()) + .await + .map_err(|err| SendRequestError::Connect(ConnectError::Io(err)))?; + + // Read the proxy response line-by-line until the blank line. + let mut buf = BytesMut::with_capacity(256); + let mut raw = [0u8; 256]; + loop { + let n = connection + .read(&mut raw) + .await + .map_err(|err| SendRequestError::Connect(ConnectError::Io(err)))?; + if n == 0 { + return Err(SendRequestError::Connect(ConnectError::Io(io::Error::new( + io::ErrorKind::UnexpectedEof, + "proxy closed connection", + )))); + } + buf.extend_from_slice(&raw[..n]); + + // The proxy response ends with "\r\n\r\n". + if buf.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + + // Check that the first line is "HTTP/1.x 200 ..." + let status_line = std::str::from_utf8(&buf) + .unwrap_or("") + .lines() + .next() + .unwrap_or(""); + + if !status_line.contains("200") { + return Err(SendRequestError::Connect(ConnectError::Io(io::Error::new( + io::ErrorKind::ConnectionRefused, + format!("proxy CONNECT failed: {status_line}"), + )))); + } + + // Consume the response bytes from the connection's read buffer so they + // do not leak into the TLS handshake / HTTP request that follows. + // The bytes are already in `buf`; the connection's stream read them from + // the socket and they are now consumed. + drop(buf); + + Ok(connection) +} diff --git a/awc/src/lib.rs b/awc/src/lib.rs index 360b3db0e..5f6e2ef4c 100644 --- a/awc/src/lib.rs +++ b/awc/src/lib.rs @@ -121,6 +121,7 @@ mod connect; pub mod error; mod frozen; pub mod middleware; +pub mod proxy; mod request; mod responses; mod sender; @@ -141,6 +142,7 @@ pub use self::{ client::{Client, Connect, Connector}, connect::{BoxConnectorService, BoxedSocket, ConnectRequest, ConnectResponse}, frozen::{FrozenClientRequest, FrozenSendBuilder}, + proxy::Proxy, request::ClientRequest, sender::SendClientRequest, }; diff --git a/awc/src/proxy.rs b/awc/src/proxy.rs new file mode 100644 index 000000000..e7f6646c4 --- /dev/null +++ b/awc/src/proxy.rs @@ -0,0 +1,200 @@ +//! HTTP proxy support for [`crate::Client`]. +//! +//! # Configuring a Proxy Explicitly +//! +//! ```no_run +//! use awc::{Client, proxy::Proxy}; +//! +//! # #[actix_rt::main] +//! # async fn main() { +//! let proxy = Proxy::new("http://proxy.example.com:8080"); +//! let client = Client::builder().proxy(proxy).finish(); +//! # } +//! ``` +//! +//! # Automatic Environment-Variable Detection +//! +//! When no proxy is set explicitly and [`crate::ClientBuilder::no_proxy`] has not been +//! called, [`Client`](crate::Client) will read `HTTPS_PROXY` (or `https_proxy`) first, then +//! `HTTP_PROXY` (or `http_proxy`) from the process environment and use the first value it +//! finds. +//! +//! ```no_run +//! // HTTP_PROXY=http://proxy.example.com:8080 cargo run +//! use awc::Client; +//! +//! # #[actix_rt::main] +//! # async fn main() { +//! // Automatically picks up the env var: +//! let client = Client::new(); +//! # } +//! ``` + +use std::{fmt, str::FromStr}; + +use actix_http::Uri; +use base64::prelude::*; +use http::header::HeaderValue; + +/// An HTTP proxy configuration. +/// +/// Can be constructed manually or read from the `HTTP_PROXY` / `HTTPS_PROXY` +/// environment variables via [`Proxy::from_env`]. +#[derive(Clone)] +pub struct Proxy { + /// Full URI of the proxy server (e.g. `http://proxy.corp:3128`). + pub(crate) uri: Uri, + + /// Optional `Proxy-Authorization` header value (pre-formatted, ready to send). + pub(crate) auth_header: Option, +} + +impl fmt::Debug for Proxy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Proxy") + .field("uri", &self.uri) + .field( + "auth_header", + &self.auth_header.as_ref().map(|_| ""), + ) + .finish() + } +} + +impl Proxy { + /// Construct a proxy that connects through `uri`. + /// + /// # Panics + /// Panics if `uri` cannot be parsed as a valid [`Uri`]. + pub fn new(uri: impl TryInto) -> Self { + let uri = uri.try_into().expect("proxy URI must be valid"); + Self { + uri, + auth_header: None, + } + } + + /// Construct a proxy with HTTP Basic authentication credentials. + /// + /// The credentials are encoded once during construction so that no heap + /// allocation occurs per-request. + /// + /// # Panics + /// Panics if `uri` cannot be parsed as a valid [`Uri`]. + pub fn new_with_creds( + uri: impl TryInto, + username: &str, + password: &str, + ) -> Self { + let raw = format!("{username}:{password}"); + let encoded = BASE64_STANDARD.encode(raw); + let header_value = HeaderValue::from_str(&format!("Basic {encoded}")) + .expect("base64-encoded credentials are always valid header values"); + + Self { + uri: uri.try_into().expect("proxy URI must be valid"), + auth_header: Some(header_value), + } + } + + /// Read an HTTP proxy from environment variables. + /// + /// Checked in order (first match wins): + /// 1. `HTTPS_PROXY` + /// 2. `https_proxy` + /// 3. `HTTP_PROXY` + /// 4. `http_proxy` + /// + /// Returns `None` when none of the variables are set or when the value + /// cannot be parsed as a [`Uri`]. + pub fn from_env() -> Option { + let candidates = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]; + + for var in candidates { + if let Ok(val) = std::env::var(var) { + if let Ok(uri) = Uri::from_str(&val) { + log::debug!("awc: using proxy from env var {var}={val}"); + return Some(Self { + uri, + auth_header: None, + }); + } + } + } + + None + } + + /// Returns the proxy's URI. + pub fn uri(&self) -> &Uri { + &self.uri + } + + /// Returns the proxy hostname (and port if non-standard) as a `String`, + /// suitable for the `Host` header of a `CONNECT` request. + #[allow(dead_code)] + pub(crate) fn host_port(&self) -> Option { + let host = self.uri.host()?; + match self.uri.port_u16() { + Some(port) => Some(format!("{host}:{port}")), + None => Some(host.to_owned()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn proxy_new_parses_uri() { + let p = Proxy::new("http://proxy.example.com:8080"); + assert_eq!(p.uri.host(), Some("proxy.example.com")); + assert_eq!(p.uri.port_u16(), Some(8080)); + assert!(p.auth_header.is_none()); + } + + #[test] + fn proxy_with_credentials_encodes_header() { + let p = Proxy::new_with_creds("http://proxy.example.com:3128", "alice", "s3cr3t"); + let hdr = p.auth_header.unwrap(); + // "alice:s3cr3t" => YWxpY2U6czNjcjN0 + assert_eq!(hdr.to_str().unwrap(), "Basic YWxpY2U6czNjcjN0"); + } + + #[test] + fn proxy_from_env_none_when_absent() { + // make sure the variable is not set in the test environment + std::env::remove_var("HTTP_PROXY"); + std::env::remove_var("http_proxy"); + std::env::remove_var("HTTPS_PROXY"); + std::env::remove_var("https_proxy"); + assert!(Proxy::from_env().is_none()); + } + + #[test] + fn proxy_from_env_reads_http_proxy() { + std::env::remove_var("HTTPS_PROXY"); + std::env::remove_var("https_proxy"); + std::env::set_var("HTTP_PROXY", "http://myproxy:3128"); + let p = Proxy::from_env().expect("should find HTTP_PROXY"); + assert_eq!(p.uri.host(), Some("myproxy")); + std::env::remove_var("HTTP_PROXY"); + } + + #[test] + fn proxy_from_env_https_takes_priority() { + std::env::set_var("HTTP_PROXY", "http://http-proxy:3128"); + std::env::set_var("HTTPS_PROXY", "http://https-proxy:8080"); + let p = Proxy::from_env().expect("should find HTTPS_PROXY"); + assert_eq!(p.uri.host(), Some("https-proxy")); + std::env::remove_var("HTTP_PROXY"); + std::env::remove_var("HTTPS_PROXY"); + } + + #[test] + fn proxy_host_port_formats_correctly() { + let p = Proxy::new("http://proxy.local:9090"); + assert_eq!(p.host_port(), Some("proxy.local:9090".to_owned())); + } +}