Feat: Add HTTP/HTTPS Proxy support to AWC (Fix #4008)

This commit is contained in:
at264939-ctrl 2026-06-20 15:50:56 +03:00
parent 7ded7ee478
commit 5b15f5fadf
5 changed files with 470 additions and 54 deletions

View File

@ -2,6 +2,7 @@
## Unreleased ## 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] - 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 `hickory-resolver` dependency to `0.26.1`.
- Update `rand` dependency to `0.10`. - Update `rand` dependency to `0.10`.

View File

@ -16,6 +16,7 @@ use crate::{
connect::DefaultConnector, connect::DefaultConnector,
error::SendRequestError, error::SendRequestError,
middleware::{NestTransform, Redirect, Transform}, middleware::{NestTransform, Redirect, Transform},
proxy::Proxy,
Client, ConnectRequest, ConnectResponse, Client, ConnectRequest, ConnectResponse,
}; };
@ -34,6 +35,10 @@ pub struct ClientBuilder<S = (), M = ()> {
middleware: M, middleware: M,
local_address: Option<IpAddr>, local_address: Option<IpAddr>,
max_redirects: u8, max_redirects: u8,
/// Explicit proxy configuration (overrides env-var detection when set).
proxy: Option<Proxy>,
/// When `true`, env-var proxy detection is disabled entirely.
disable_proxy: bool,
} }
impl ClientBuilder { impl ClientBuilder {
@ -63,6 +68,8 @@ impl ClientBuilder {
middleware: (), middleware: (),
local_address: None, local_address: None,
max_redirects: 10, max_redirects: 10,
proxy: None,
disable_proxy: false,
} }
} }
} }
@ -93,6 +100,8 @@ where
stream_window_size: self.stream_window_size, stream_window_size: self.stream_window_size,
conn_window_size: self.conn_window_size, conn_window_size: self.conn_window_size,
max_redirects: self.max_redirects, max_redirects: self.max_redirects,
proxy: self.proxy,
disable_proxy: self.disable_proxy,
} }
} }
@ -141,6 +150,42 @@ where
self 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 /// Indicates the initial window size (in octets) for
/// HTTP2 stream-level flow control for received data. /// HTTP2 stream-level flow control for received data.
/// ///
@ -245,6 +290,8 @@ where
connector: self.connector, connector: self.connector,
local_address: self.local_address, local_address: self.local_address,
max_redirects: self.max_redirects, max_redirects: self.max_redirects,
proxy: self.proxy,
disable_proxy: self.disable_proxy,
} }
} }
@ -284,7 +331,14 @@ where
connector = connector.local_address(val); 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)); let connector = boxed::rc_service(self.middleware.new_transform(connector));
Client(ClientConfig { Client(ClientConfig {

View File

@ -1,6 +1,6 @@
use std::{ use std::{
future::Future, future::Future,
net, io, net,
pin::Pin, pin::Pin,
rc::Rc, rc::Rc,
task::{Context, Poll}, task::{Context, Poll},
@ -9,11 +9,15 @@ use std::{
use actix_codec::Framed; use actix_codec::Framed;
use actix_http::{h1::ClientCodec, Payload, RequestHead, RequestHeadType, ResponseHead}; use actix_http::{h1::ClientCodec, Payload, RequestHead, RequestHeadType, ResponseHead};
use actix_service::Service; use actix_service::Service;
use bytes::BytesMut;
use futures_core::{future::LocalBoxFuture, ready}; use futures_core::{future::LocalBoxFuture, ready};
use http::header::HeaderValue;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::{ use crate::{
any_body::AnyBody, any_body::AnyBody,
client::{Connect as ClientConnect, ConnectError, Connection, ConnectionIo, SendRequestError}, client::{Connect as ClientConnect, ConnectError, Connection, ConnectionIo, SendRequestError},
proxy::Proxy,
ClientResponse, ClientResponse,
}; };
@ -82,11 +86,12 @@ impl ConnectResponse {
pub struct DefaultConnector<S> { pub struct DefaultConnector<S> {
connector: S, connector: S,
proxy: Option<Proxy>,
} }
impl<S> DefaultConnector<S> { impl<S> DefaultConnector<S> {
pub(crate) fn new(connector: S) -> Self { pub(crate) fn new(connector: S, proxy: Option<Proxy>) -> Self {
Self { connector } Self { connector, proxy }
} }
} }
@ -102,21 +107,63 @@ where
actix_service::forward_ready!(connector); actix_service::forward_ready!(connector);
fn call(&self, req: ConnectRequest) -> Self::Future { fn call(&self, req: ConnectRequest) -> Self::Future {
// connect to the host // Determine the target URI and whether we need a CONNECT tunnel.
let fut = match req { let (target_uri, need_tunnel, tunnel_host) = match &req {
ConnectRequest::Client(ref head, .., addr) => self.connector.call(ClientConnect { ConnectRequest::Client(head, ..) => {
uri: head.as_ref().uri.clone(), let uri = &head.as_ref().uri;
addr, let is_https = uri.scheme_str() == Some("https");
}), let host_port = format!(
ConnectRequest::Tunnel(ref head, addr) => self.connector.call(ClientConnect { "{}:{}",
uri: head.uri.clone(), uri.host().unwrap_or(""),
addr, 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 { ConnectRequestFuture::Connection {
fut, fut,
req: Some(req), req: Some(req),
proxy_auth,
proxy_tunnel_host,
} }
} }
} }
@ -130,17 +177,20 @@ pin_project_lite::pin_project! {
Connection { Connection {
#[pin] #[pin]
fut: Fut, fut: Fut,
req: Option<ConnectRequest> req: Option<ConnectRequest>,
}, proxy_auth: Option<HeaderValue>,
Client { proxy_tunnel_host: Option<String>,
fut: LocalBoxFuture<'static, Result<(ResponseHead, Payload), SendRequestError>>
}, },
Tunnel { Tunnel {
fut: LocalBoxFuture< fut: LocalBoxFuture<'static, Result<(ResponseHead, Framed<Connection<Io>, ClientCodec>), SendRequestError>>,
'static, },
Result<(ResponseHead, Framed<Connection<Io>, ClientCodec>), SendRequestError>, Client {
>, fut: LocalBoxFuture<'static, Result<(ResponseHead, Payload), SendRequestError>>,
} },
ProxyConnect {
fut: LocalBoxFuture<'static, Result<Connection<Io>, SendRequestError>>,
req: Option<ConnectRequest>,
},
} }
} }
@ -152,46 +202,155 @@ where
type Output = Result<ConnectResponse, SendRequestError>; type Output = Result<ConnectResponse, SendRequestError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match self.as_mut().project() { loop {
ConnectRequestProj::Connection { fut, req } => { match self.as_mut().project() {
let connection = ready!(fut.poll(cx))?; ConnectRequestProj::Connection {
let req = req.take().unwrap(); fut,
req,
proxy_auth,
proxy_tunnel_host,
} => {
let connection = ready!(fut.poll(cx))?;
let req = req.take().unwrap();
match req { if let Some(host) = proxy_tunnel_host.take() {
ConnectRequest::Client(head, body, ..) => { // We connected to the proxy over TCP. Now negotiate a CONNECT
// send request // tunnel so subsequent TLS/HTTP2 traffic goes to the real target.
let fut = ConnectRequestFuture::Client { let auth_hdr = proxy_auth.take();
fut: connection.send_request(head, body), let fut = Box::pin(proxy_connect_tunnel(connection, host, auth_hdr));
}; self.set(ConnectRequestFuture::ProxyConnect {
fut,
self.set(fut); req: Some(req),
});
// Loop back to poll the new state immediately.
continue;
} }
ConnectRequest::Tunnel(head, ..) => { match req {
// send request ConnectRequest::Client(head, body, ..) => {
let fut = ConnectRequestFuture::Tunnel { let fut = ConnectRequestFuture::Client {
fut: connection.open_tunnel(RequestHeadType::from(head)), fut: connection.send_request(head, body),
}; };
self.set(fut);
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 } => { match req {
let (head, payload) = ready!(fut.as_mut().poll(cx))?; ConnectRequest::Client(head, body, ..) => {
Poll::Ready(Ok(ConnectResponse::Client(ClientResponse::new( let inner_fut = ConnectRequestFuture::Client {
head, payload, 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 } => { ConnectRequestProj::Client { fut } => {
let (head, framed) = ready!(fut.as_mut().poll(cx))?; let (head, payload) = ready!(fut.as_mut().poll(cx))?;
let framed = framed.into_map_io(|io| Box::new(io) as _); return Poll::Ready(Ok(ConnectResponse::Client(ClientResponse::new(
Poll::Ready(Ok(ConnectResponse::Tunnel(head, framed))) 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<Io: ConnectionIo>(
mut connection: Connection<Io>,
host: String,
auth: Option<HeaderValue>,
) -> Result<Connection<Io>, 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<Io>` 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)
}

View File

@ -121,6 +121,7 @@ mod connect;
pub mod error; pub mod error;
mod frozen; mod frozen;
pub mod middleware; pub mod middleware;
pub mod proxy;
mod request; mod request;
mod responses; mod responses;
mod sender; mod sender;
@ -141,6 +142,7 @@ pub use self::{
client::{Client, Connect, Connector}, client::{Client, Connect, Connector},
connect::{BoxConnectorService, BoxedSocket, ConnectRequest, ConnectResponse}, connect::{BoxConnectorService, BoxedSocket, ConnectRequest, ConnectResponse},
frozen::{FrozenClientRequest, FrozenSendBuilder}, frozen::{FrozenClientRequest, FrozenSendBuilder},
proxy::Proxy,
request::ClientRequest, request::ClientRequest,
sender::SendClientRequest, sender::SendClientRequest,
}; };

200
awc/src/proxy.rs Normal file
View File

@ -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<HeaderValue>,
}
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(|_| "<redacted>"),
)
.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<Uri, Error = impl fmt::Debug>) -> 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<Uri, Error = impl fmt::Debug>,
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<Self> {
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<String> {
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()));
}
}