mirror of https://github.com/fafhrd91/actix-web
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:
parent
7ded7ee478
commit
c885158718
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
## 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]
|
||||
- Wake HTTP/1 payload receivers with an incomplete-payload error when the sender is dropped before EOF. [#3100]
|
||||
- Update `foldhash` dependency to `0.2`.
|
||||
|
|
|
|||
|
|
@ -305,11 +305,23 @@ impl MessageType for RequestHeadType {
|
|||
fn encode_status(&mut self, dst: &mut BytesMut) -> io::Result<()> {
|
||||
let head = self.as_ref();
|
||||
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!(
|
||||
helpers::MutWriter(dst),
|
||||
"{} {} {}",
|
||||
head.method,
|
||||
head.uri.path_and_query().map(|u| u.as_str()).unwrap_or("/"),
|
||||
request_target,
|
||||
match head.version {
|
||||
Version::HTTP_09 => "HTTP/0.9",
|
||||
Version::HTTP_10 => "HTTP/1.0",
|
||||
|
|
@ -664,6 +676,42 @@ mod tests {
|
|||
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]
|
||||
async fn test_no_content_length() {
|
||||
let mut bytes = BytesMut::with_capacity(2048);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ bitflags! {
|
|||
const UPGRADE = 0b0000_0100;
|
||||
const EXPECT = 0b0000_1000;
|
||||
const NO_CHUNKING = 0b0001_0000;
|
||||
const CAMEL_CASE = 0b0010_0000;
|
||||
const CAMEL_CASE = 0b0010_0000;
|
||||
const PROXY_REQUEST = 0b0100_0000;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,25 @@ impl RequestHead {
|
|||
pub(crate) fn set_expect(&mut self) {
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## 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]
|
||||
- Update `hickory-resolver` dependency to `0.26.1`.
|
||||
- Update `rand` dependency to `0.10`.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ use base64::prelude::*;
|
|||
|
||||
use crate::{
|
||||
client::{
|
||||
ClientConfig, ConnectInfo, Connector, ConnectorService, TcpConnectError, TcpConnection,
|
||||
proxy::ProxyConfig, ClientConfig, ConnectInfo, Connector, ConnectorService,
|
||||
TcpConnectError, TcpConnection,
|
||||
},
|
||||
connect::DefaultConnector,
|
||||
error::SendRequestError,
|
||||
|
|
@ -34,6 +35,7 @@ pub struct ClientBuilder<S = (), M = ()> {
|
|||
middleware: M,
|
||||
local_address: Option<IpAddr>,
|
||||
max_redirects: u8,
|
||||
proxy: ProxyConfig,
|
||||
}
|
||||
|
||||
impl ClientBuilder {
|
||||
|
|
@ -63,6 +65,7 @@ impl ClientBuilder {
|
|||
middleware: (),
|
||||
local_address: None,
|
||||
max_redirects: 10,
|
||||
proxy: ProxyConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +96,7 @@ where
|
|||
stream_window_size: self.stream_window_size,
|
||||
conn_window_size: self.conn_window_size,
|
||||
max_redirects: self.max_redirects,
|
||||
proxy: self.proxy,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -159,6 +163,22 @@ where
|
|||
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.
|
||||
///
|
||||
/// By default `Date` and `User-Agent` headers are set.
|
||||
|
|
@ -245,6 +265,7 @@ where
|
|||
connector: self.connector,
|
||||
local_address: self.local_address,
|
||||
max_redirects: self.max_redirects,
|
||||
proxy: self.proxy,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -284,7 +305,7 @@ where
|
|||
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));
|
||||
|
||||
Client(ClientConfig {
|
||||
|
|
|
|||
|
|
@ -18,11 +18,13 @@ mod error;
|
|||
mod h1proto;
|
||||
mod h2proto;
|
||||
mod pool;
|
||||
pub(crate) mod proxy;
|
||||
|
||||
pub use self::{
|
||||
connection::{Connection, ConnectionIo},
|
||||
connector::{Connector, ConnectorService},
|
||||
error::{ConnectError, FreezeRequestError, InvalidUrl, SendRequestError},
|
||||
proxy::ProxyConfig,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,10 @@ use futures_core::{future::LocalBoxFuture, ready};
|
|||
|
||||
use crate::{
|
||||
any_body::AnyBody,
|
||||
client::{Connect as ClientConnect, ConnectError, Connection, ConnectionIo, SendRequestError},
|
||||
client::{
|
||||
proxy::ProxyConfig, Connect as ClientConnect, ConnectError, Connection, ConnectionIo,
|
||||
SendRequestError,
|
||||
},
|
||||
ClientResponse,
|
||||
};
|
||||
|
||||
|
|
@ -82,11 +85,20 @@ impl ConnectResponse {
|
|||
|
||||
pub struct DefaultConnector<S> {
|
||||
connector: S,
|
||||
proxy: ProxyConfig,
|
||||
}
|
||||
|
||||
impl<S> DefaultConnector<S> {
|
||||
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);
|
||||
|
||||
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,
|
||||
}),
|
||||
fn call(&self, mut req: ConnectRequest) -> Self::Future {
|
||||
let (target_uri, addr) = match req {
|
||||
ConnectRequest::Client(ref head, .., addr) => (head.as_ref().uri.clone(), addr),
|
||||
ConnectRequest::Tunnel(ref head, addr) => (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 {
|
||||
fut,
|
||||
req: Some(req),
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ pub mod http {
|
|||
pub use self::responses::{ClientResponse, JsonBody, MessageBody, ResponseBody};
|
||||
pub use self::{
|
||||
builder::ClientBuilder,
|
||||
client::{Client, Connect, Connector},
|
||||
client::{Client, Connect, Connector, ProxyConfig},
|
||||
connect::{BoxConnectorService, BoxedSocket, ConnectRequest, ConnectResponse},
|
||||
frozen::{FrozenClientRequest, FrozenSendBuilder},
|
||||
request::ClientRequest,
|
||||
|
|
|
|||
Loading…
Reference in New Issue