feat(awc): add HTTP proxy support via environment variables

Add support for HTTP_PROXY/HTTPS_PROXY/NO_PROXY environment variables
to the awc HTTP client, matching the behavior of reqwest and other
HTTP clients. Proxied requests use absolute-form URIs per RFC 7230
section 5.3.2.

Changes:
- actix-http: Add PROXY_REQUEST flag and absolute-form URI encoding
- awc: Add ProxyConfig, ClientBuilder::proxy()/no_proxy() methods
- awc: DefaultConnector routes connections through proxy when configured
- Proxy env vars are read by default; use no_proxy() to disable
This commit is contained in:
Dhinesh K 2026-06-18 21:05:44 +05:30
parent 7ded7ee478
commit c885158718
10 changed files with 361 additions and 18 deletions

View File

@ -2,6 +2,7 @@
## Unreleased ## Unreleased
- Add `RequestHead::set_proxy_request()` / `is_proxy_request()` to support absolute-form URI encoding for HTTP proxy requests (RFC 7230 section 5.3.2).
- When configured, gracefully close HTTP/1 connections after early responses to unread request bodies. [#3967] - When configured, gracefully close HTTP/1 connections after early responses to unread request bodies. [#3967]
- Wake HTTP/1 payload receivers with an incomplete-payload error when the sender is dropped before EOF. [#3100] - Wake HTTP/1 payload receivers with an incomplete-payload error when the sender is dropped before EOF. [#3100]
- Update `foldhash` dependency to `0.2`. - Update `foldhash` dependency to `0.2`.

View File

@ -305,11 +305,23 @@ impl MessageType for RequestHeadType {
fn encode_status(&mut self, dst: &mut BytesMut) -> io::Result<()> { fn encode_status(&mut self, dst: &mut BytesMut) -> io::Result<()> {
let head = self.as_ref(); let head = self.as_ref();
dst.reserve(256 + head.headers.len() * AVERAGE_HEADER_SIZE); dst.reserve(256 + head.headers.len() * AVERAGE_HEADER_SIZE);
// RFC 7230 section 5.3.2: use absolute-form URI for proxy requests
let request_target = if head.is_proxy_request() {
head.uri.to_string()
} else {
head.uri
.path_and_query()
.map(|u| u.as_str())
.unwrap_or("/")
.to_owned()
};
write!( write!(
helpers::MutWriter(dst), helpers::MutWriter(dst),
"{} {} {}", "{} {} {}",
head.method, head.method,
head.uri.path_and_query().map(|u| u.as_str()).unwrap_or("/"), request_target,
match head.version { match head.version {
Version::HTTP_09 => "HTTP/0.9", Version::HTTP_09 => "HTTP/0.9",
Version::HTTP_10 => "HTTP/1.0", Version::HTTP_10 => "HTTP/1.0",
@ -664,6 +676,42 @@ mod tests {
assert!(data.contains("date: date\r\n")); assert!(data.contains("date: date\r\n"));
} }
#[test]
fn test_proxy_request_absolute_uri() {
let mut bytes = BytesMut::with_capacity(2048);
let mut head = RequestHead::default();
head.uri = "http://example.com/foo?bar=1".parse().unwrap();
head.method = http::Method::GET;
head.set_proxy_request(true);
let mut head = RequestHeadType::Owned(head);
head.encode_status(&mut bytes).unwrap();
let data = String::from_utf8(Vec::from(bytes.split().freeze().as_ref())).unwrap();
assert!(
data.starts_with("GET http://example.com/foo?bar=1 HTTP/1.1"),
"Expected absolute-form URI for proxy request, got: {data}",
);
}
#[test]
fn test_non_proxy_request_origin_form() {
let mut bytes = BytesMut::with_capacity(2048);
let mut head = RequestHead::default();
head.uri = "http://example.com/foo?bar=1".parse().unwrap();
head.method = http::Method::GET;
// proxy flag is NOT set
let mut head = RequestHeadType::Owned(head);
head.encode_status(&mut bytes).unwrap();
let data = String::from_utf8(Vec::from(bytes.split().freeze().as_ref())).unwrap();
assert!(
data.starts_with("GET /foo?bar=1 HTTP/1.1"),
"Expected origin-form URI for non-proxy request, got: {data}",
);
}
#[actix_rt::test] #[actix_rt::test]
async fn test_no_content_length() { async fn test_no_content_length() {
let mut bytes = BytesMut::with_capacity(2048); let mut bytes = BytesMut::with_capacity(2048);

View File

@ -23,7 +23,8 @@ bitflags! {
const UPGRADE = 0b0000_0100; const UPGRADE = 0b0000_0100;
const EXPECT = 0b0000_1000; const EXPECT = 0b0000_1000;
const NO_CHUNKING = 0b0001_0000; const NO_CHUNKING = 0b0001_0000;
const CAMEL_CASE = 0b0010_0000; const CAMEL_CASE = 0b0010_0000;
const PROXY_REQUEST = 0b0100_0000;
} }
} }

View File

@ -144,6 +144,25 @@ impl RequestHead {
pub(crate) fn set_expect(&mut self) { pub(crate) fn set_expect(&mut self) {
self.flags.insert(Flags::EXPECT); self.flags.insert(Flags::EXPECT);
} }
/// Returns whether this request should use absolute-form URI (for proxy requests).
#[inline]
pub fn is_proxy_request(&self) -> bool {
self.flags.contains(Flags::PROXY_REQUEST)
}
/// Sets whether this request should use absolute-form URI in the request line.
///
/// When enabled, the full URI (including scheme and authority) is sent in the request line,
/// as required by HTTP/1.1 for requests to proxies (RFC 7230 section 5.3.2).
#[inline]
pub fn set_proxy_request(&mut self, val: bool) {
if val {
self.flags.insert(Flags::PROXY_REQUEST);
} else {
self.flags.remove(Flags::PROXY_REQUEST);
}
}
} }
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]

View File

@ -2,6 +2,8 @@
## Unreleased ## Unreleased
- Add HTTP proxy support via `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` environment variables, read by default.
- Add `ProxyConfig` type and `ClientBuilder::proxy()` / `ClientBuilder::no_proxy()` methods for explicit proxy configuration.
- 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

@ -11,7 +11,8 @@ use base64::prelude::*;
use crate::{ use crate::{
client::{ client::{
ClientConfig, ConnectInfo, Connector, ConnectorService, TcpConnectError, TcpConnection, proxy::ProxyConfig, ClientConfig, ConnectInfo, Connector, ConnectorService,
TcpConnectError, TcpConnection,
}, },
connect::DefaultConnector, connect::DefaultConnector,
error::SendRequestError, error::SendRequestError,
@ -34,6 +35,7 @@ pub struct ClientBuilder<S = (), M = ()> {
middleware: M, middleware: M,
local_address: Option<IpAddr>, local_address: Option<IpAddr>,
max_redirects: u8, max_redirects: u8,
proxy: ProxyConfig,
} }
impl ClientBuilder { impl ClientBuilder {
@ -63,6 +65,7 @@ impl ClientBuilder {
middleware: (), middleware: (),
local_address: None, local_address: None,
max_redirects: 10, max_redirects: 10,
proxy: ProxyConfig::from_env(),
} }
} }
} }
@ -93,6 +96,7 @@ 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,
} }
} }
@ -159,6 +163,22 @@ where
self self
} }
/// Set proxy configuration.
///
/// By default, proxy settings are read from environment variables
/// (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`). Use this method to override with
/// an explicit configuration.
pub fn proxy(mut self, proxy: ProxyConfig) -> Self {
self.proxy = proxy;
self
}
/// Disable proxy usage, ignoring environment variables.
pub fn no_proxy(mut self) -> Self {
self.proxy = ProxyConfig::default();
self
}
/// Do not add fundamental default request headers. /// Do not add fundamental default request headers.
/// ///
/// By default `Date` and `User-Agent` headers are set. /// By default `Date` and `User-Agent` headers are set.
@ -245,6 +265,7 @@ 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,
} }
} }
@ -284,7 +305,7 @@ where
connector = connector.local_address(val); connector = connector.local_address(val);
} }
let connector = DefaultConnector::new(connector.finish()); let connector = DefaultConnector::new(connector.finish()).with_proxy(self.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

@ -18,11 +18,13 @@ mod error;
mod h1proto; mod h1proto;
mod h2proto; mod h2proto;
mod pool; mod pool;
pub(crate) mod proxy;
pub use self::{ pub use self::{
connection::{Connection, ConnectionIo}, connection::{Connection, ConnectionIo},
connector::{Connector, ConnectorService}, connector::{Connector, ConnectorService},
error::{ConnectError, FreezeRequestError, InvalidUrl, SendRequestError}, error::{ConnectError, FreezeRequestError, InvalidUrl, SendRequestError},
proxy::ProxyConfig,
}; };
#[derive(Clone)] #[derive(Clone)]

223
awc/src/client/proxy.rs Normal file
View File

@ -0,0 +1,223 @@
use std::env;
use http::Uri;
/// HTTP proxy configuration for the client connector.
///
/// Determines which proxy (if any) should be used for a given request URI,
/// based on explicit configuration or environment variables (`HTTP_PROXY`,
/// `HTTPS_PROXY`, `NO_PROXY`).
#[derive(Clone, Debug)]
pub struct ProxyConfig {
http_proxy: Option<Uri>,
https_proxy: Option<Uri>,
no_proxy: Option<NoProxy>,
}
impl ProxyConfig {
/// Creates a proxy configuration by reading standard environment variables.
///
/// Reads `HTTP_PROXY` (or `http_proxy`), `HTTPS_PROXY` (or `https_proxy`),
/// and `NO_PROXY` (or `no_proxy`).
pub fn from_env() -> Self {
let http_proxy = env_var_uri("HTTP_PROXY").or_else(|| env_var_uri("http_proxy"));
let https_proxy = env_var_uri("HTTPS_PROXY").or_else(|| env_var_uri("https_proxy"));
let no_proxy = env::var("NO_PROXY")
.or_else(|_| env::var("no_proxy"))
.ok()
.map(|s| NoProxy::parse(&s));
Self {
http_proxy,
https_proxy,
no_proxy,
}
}
/// Creates a proxy configuration with an explicit HTTP proxy URI.
pub fn with_http_proxy(mut self, proxy: Uri) -> Self {
self.http_proxy = Some(proxy);
self
}
/// Creates a proxy configuration with an explicit HTTPS proxy URI.
pub fn with_https_proxy(mut self, proxy: Uri) -> Self {
self.https_proxy = Some(proxy);
self
}
/// Sets the no-proxy list (comma-separated hostnames/domains).
pub fn with_no_proxy(mut self, no_proxy: &str) -> Self {
self.no_proxy = Some(NoProxy::parse(no_proxy));
self
}
/// Returns the proxy URI to use for the given target URI, or `None` if no proxy applies.
pub fn proxy_for_uri(&self, uri: &Uri) -> Option<&Uri> {
if let Some(ref no_proxy) = self.no_proxy {
if let Some(host) = uri.host() {
if no_proxy.matches(host) {
return None;
}
}
}
match uri.scheme_str() {
Some("https") | Some("wss") => self.https_proxy.as_ref(),
_ => self.http_proxy.as_ref(),
}
}
/// Returns `true` if any proxy is configured.
pub fn has_proxy(&self) -> bool {
self.http_proxy.is_some() || self.https_proxy.is_some()
}
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
http_proxy: None,
https_proxy: None,
no_proxy: None,
}
}
}
fn env_var_uri(name: &str) -> Option<Uri> {
env::var(name)
.ok()
.and_then(|val| {
if val.is_empty() {
return None;
}
match val.parse::<Uri>() {
Ok(uri) => Some(uri),
Err(err) => {
log::warn!("Invalid proxy URI in {name}: {err}");
None
}
}
})
}
/// Parsed representation of the `NO_PROXY` environment variable.
#[derive(Clone, Debug)]
struct NoProxy {
entries: Vec<String>,
match_all: bool,
}
impl NoProxy {
fn parse(value: &str) -> Self {
let trimmed = value.trim();
if trimmed == "*" {
return Self {
entries: Vec::new(),
match_all: true,
};
}
let entries = trimmed
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect();
Self {
entries,
match_all: false,
}
}
fn matches(&self, host: &str) -> bool {
if self.match_all {
return true;
}
let host = host.to_lowercase();
self.entries.iter().any(|entry| {
if entry.starts_with('.') {
// ".example.com" matches "foo.example.com" and "example.com"
host.ends_with(entry.as_str()) || host == entry[1..]
} else {
host == *entry || host.ends_with(&format!(".{entry}"))
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_proxy_wildcard() {
let np = NoProxy::parse("*");
assert!(np.matches("anything.example.com"));
assert!(np.matches("localhost"));
}
#[test]
fn no_proxy_exact_match() {
let np = NoProxy::parse("localhost, example.com");
assert!(np.matches("localhost"));
assert!(np.matches("example.com"));
assert!(!np.matches("other.com"));
}
#[test]
fn no_proxy_subdomain_match() {
let np = NoProxy::parse("example.com");
assert!(np.matches("example.com"));
assert!(np.matches("sub.example.com"));
assert!(!np.matches("notexample.com"));
}
#[test]
fn no_proxy_dot_prefix() {
let np = NoProxy::parse(".example.com");
assert!(np.matches("example.com"));
assert!(np.matches("sub.example.com"));
assert!(!np.matches("notexample.com"));
}
#[test]
fn no_proxy_case_insensitive() {
let np = NoProxy::parse("Example.COM");
assert!(np.matches("example.com"));
assert!(np.matches("EXAMPLE.COM"));
}
#[test]
fn proxy_for_uri_http() {
let config = ProxyConfig::default()
.with_http_proxy("http://proxy:8080".parse().unwrap());
let uri: Uri = "http://example.com/foo".parse().unwrap();
assert!(config.proxy_for_uri(&uri).is_some());
}
#[test]
fn proxy_for_uri_no_proxy() {
let config = ProxyConfig::default()
.with_http_proxy("http://proxy:8080".parse().unwrap())
.with_no_proxy("example.com");
let uri: Uri = "http://example.com/foo".parse().unwrap();
assert!(config.proxy_for_uri(&uri).is_none());
}
#[test]
fn proxy_for_uri_https() {
let config = ProxyConfig::default()
.with_https_proxy("http://proxy:8080".parse().unwrap());
let uri: Uri = "https://example.com/foo".parse().unwrap();
assert!(config.proxy_for_uri(&uri).is_some());
}
#[test]
fn no_proxy_empty_string() {
let np = NoProxy::parse("");
assert!(!np.matches("anything"));
}
}

View File

@ -13,7 +13,10 @@ use futures_core::{future::LocalBoxFuture, ready};
use crate::{ use crate::{
any_body::AnyBody, any_body::AnyBody,
client::{Connect as ClientConnect, ConnectError, Connection, ConnectionIo, SendRequestError}, client::{
proxy::ProxyConfig, Connect as ClientConnect, ConnectError, Connection, ConnectionIo,
SendRequestError,
},
ClientResponse, ClientResponse,
}; };
@ -82,11 +85,20 @@ impl ConnectResponse {
pub struct DefaultConnector<S> { pub struct DefaultConnector<S> {
connector: S, connector: S,
proxy: ProxyConfig,
} }
impl<S> DefaultConnector<S> { impl<S> DefaultConnector<S> {
pub(crate) fn new(connector: S) -> Self { pub(crate) fn new(connector: S) -> Self {
Self { connector } Self {
connector,
proxy: ProxyConfig::default(),
}
}
pub(crate) fn with_proxy(mut self, proxy: ProxyConfig) -> Self {
self.proxy = proxy;
self
} }
} }
@ -101,19 +113,33 @@ where
actix_service::forward_ready!(connector); actix_service::forward_ready!(connector);
fn call(&self, req: ConnectRequest) -> Self::Future { fn call(&self, mut req: ConnectRequest) -> Self::Future {
// connect to the host let (target_uri, addr) = match req {
let fut = match req { ConnectRequest::Client(ref head, .., addr) => (head.as_ref().uri.clone(), addr),
ConnectRequest::Client(ref head, .., addr) => self.connector.call(ClientConnect { ConnectRequest::Tunnel(ref head, addr) => (head.uri.clone(), addr),
uri: head.as_ref().uri.clone(),
addr,
}),
ConnectRequest::Tunnel(ref head, addr) => self.connector.call(ClientConnect {
uri: head.uri.clone(),
addr,
}),
}; };
let connect_uri = if let Some(proxy_uri) = self.proxy.proxy_for_uri(&target_uri) {
// Set the proxy flag on the request head so the encoder uses absolute-form URI
match req {
ConnectRequest::Client(ref mut head, ..) => match head {
RequestHeadType::Owned(ref mut h) => h.set_proxy_request(true),
RequestHeadType::Rc(_, _) => {
log::warn!("Cannot set proxy flag on Rc<RequestHead>; request will use origin-form URI");
}
},
ConnectRequest::Tunnel(..) => {}
}
proxy_uri.clone()
} else {
target_uri
};
let fut = self.connector.call(ClientConnect {
uri: connect_uri,
addr,
});
ConnectRequestFuture::Connection { ConnectRequestFuture::Connection {
fut, fut,
req: Some(req), req: Some(req),

View File

@ -138,7 +138,7 @@ pub mod http {
pub use self::responses::{ClientResponse, JsonBody, MessageBody, ResponseBody}; pub use self::responses::{ClientResponse, JsonBody, MessageBody, ResponseBody};
pub use self::{ pub use self::{
builder::ClientBuilder, builder::ClientBuilder,
client::{Client, Connect, Connector}, client::{Client, Connect, Connector, ProxyConfig},
connect::{BoxConnectorService, BoxedSocket, ConnectRequest, ConnectResponse}, connect::{BoxConnectorService, BoxedSocket, ConnectRequest, ConnectResponse},
frozen::{FrozenClientRequest, FrozenSendBuilder}, frozen::{FrozenClientRequest, FrozenSendBuilder},
request::ClientRequest, request::ClientRequest,