#![cfg(feature = "tokio")]
use std::convert::Infallible;
use std::sync::Arc;
use bytes::Bytes;
use http_body_util::Full;
use hyper::Response;
use tokio::net::TcpListener;
use aioduct::HttpEngineSend;
use aioduct::runtime::TokioRuntime;
use aioduct::runtime::tokio_rt::TcpConnector;
use aioduct_test_server::h1::{h1_server, h1_server_with};
use aioduct_test_server::raw::raw_server;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering};
#[tokio::test]
async fn test_http_proxy() {
let (target_addr, _counter) = h1_server().await;
let (proxy_addr, _conns) = connect_proxy().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap())
.build()
.unwrap();
let resp = client
.get(&format!("http://{target_addr}/path"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
#[tokio::test]
async fn test_proxy_settings_no_proxy_bypass() {
let (target_addr, _counter) = h1_server().await;
let (proxy_addr, _conns) = connect_proxy().await;
let (other_addr, _counter) = h1_server_with(|_req| async move {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("proxied-ok"))))
})
.await;
let settings = aioduct::ProxySettings::all(
aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap(),
)
.no_proxy(aioduct::NoProxy::new(&format!("{}", target_addr.ip())));
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy_settings(settings)
.build()
.unwrap();
let resp = client
.get(&format!("http://{target_addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
let resp = client
.get(&format!("http://{other_addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "proxied-ok");
}
#[tokio::test]
async fn test_no_proxy_wildcard_bypasses_all() {
let (target_addr, _counter) = h1_server_with(|_req| async move {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("direct"))))
})
.await;
let settings =
aioduct::ProxySettings::all(aioduct::ProxyConfig::http("http://127.0.0.1:9999").unwrap())
.no_proxy(aioduct::NoProxy::new("*"));
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy_settings(settings)
.build()
.unwrap();
let resp = client
.get(&format!("http://{target_addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "direct");
}
#[tokio::test]
async fn test_no_proxy_domain_suffix_matching() {
let no_proxy = aioduct::NoProxy::new(".example.com, localhost");
assert!(!no_proxy.matches("example.com")); assert!(no_proxy.matches("foo.example.com"));
assert!(no_proxy.matches("bar.baz.example.com"));
assert!(no_proxy.matches("localhost"));
assert!(!no_proxy.matches("notexample.com"));
assert!(!no_proxy.matches("other.com"));
}
#[tokio::test]
async fn test_no_proxy_bare_domain_matches_subdomains() {
let no_proxy = aioduct::NoProxy::new("example.com");
assert!(no_proxy.matches("example.com"));
assert!(no_proxy.matches("foo.example.com"));
assert!(!no_proxy.matches("notexample.com"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_socks5_proxy() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (target_addr, _counter) = h1_server().await;
let socks_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let socks_addr = socks_listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut client, _) = socks_listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 256];
let n = client.read(&mut buf).await.unwrap();
assert!(n >= 3);
assert_eq!(buf[0], 0x05);
client.write_all(&[0x05, 0x00]).await.unwrap();
let n = client.read(&mut buf).await.unwrap();
assert!(n >= 7);
assert_eq!(buf[0], 0x05); assert_eq!(buf[1], 0x01); assert!(
buf[3] == 0x01 || buf[3] == 0x04,
"expected IPv4 or IPv6 ATYP, got {:#04x}",
buf[3]
);
let port = match buf[3] {
0x01 => u16::from_be_bytes([buf[8], buf[9]]),
0x04 => u16::from_be_bytes([buf[20], buf[21]]),
_ => unreachable!(),
};
let target = format!("127.0.0.1:{port}");
let mut upstream = tokio::net::TcpStream::connect(target).await.unwrap();
client
.write_all(&[0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
.await
.unwrap();
let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream).await;
});
}
});
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(aioduct::ProxyConfig::socks5(&format!("socks5://{socks_addr}")).unwrap())
.build()
.unwrap();
let resp = client
.get(&format!("http://localhost:{}/", target_addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_socks5_proxy_with_auth() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (target_addr, _counter) = h1_server().await;
let socks_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let socks_addr = socks_listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut client, _) = socks_listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 256];
let n = client.read(&mut buf).await.unwrap();
assert!(n >= 3);
assert_eq!(buf[0], 0x05);
client.write_all(&[0x05, 0x02]).await.unwrap();
let n = client.read(&mut buf).await.unwrap();
assert!(n >= 3);
assert_eq!(buf[0], 0x01); let ulen = buf[1] as usize;
let username = String::from_utf8_lossy(&buf[2..2 + ulen]).to_string();
let plen = buf[2 + ulen] as usize;
let password = String::from_utf8_lossy(&buf[3 + ulen..3 + ulen + plen]).to_string();
if username == "testuser" && password == "testpass" {
client.write_all(&[0x01, 0x00]).await.unwrap();
} else {
client.write_all(&[0x01, 0x01]).await.unwrap();
return;
}
let n = client.read(&mut buf).await.unwrap();
assert!(n >= 7);
let port = match buf[3] {
0x01 => u16::from_be_bytes([buf[8], buf[9]]),
0x03 => {
let domain_len = buf[4] as usize;
let port_offset = 5 + domain_len;
u16::from_be_bytes([buf[port_offset], buf[port_offset + 1]])
}
0x04 => u16::from_be_bytes([buf[20], buf[21]]),
_ => panic!("unexpected ATYP: {:#04x}", buf[3]),
};
let target = format!("127.0.0.1:{port}");
let mut upstream = tokio::net::TcpStream::connect(target).await.unwrap();
client
.write_all(&[0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
.await
.unwrap();
let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream).await;
});
}
});
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(
aioduct::ProxyConfig::socks5(&format!("socks5://{socks_addr}"))
.unwrap()
.basic_auth("testuser", "testpass"),
)
.build()
.unwrap();
let resp = client
.get(&format!("http://localhost:{}/", target_addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_socks5h_proxy() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (target_addr, _counter) = h1_server().await;
let socks_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let socks_addr = socks_listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut client, _) = socks_listener.accept().await.unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 256];
let n = client.read(&mut buf).await.unwrap();
assert!(n >= 3);
assert_eq!(buf[0], 0x05);
client.write_all(&[0x05, 0x00]).await.unwrap();
let n = client.read(&mut buf).await.unwrap();
assert!(n >= 7);
assert_eq!(buf[0], 0x05); assert_eq!(buf[1], 0x01); assert_eq!(buf[3], 0x03);
let domain_len = buf[4] as usize;
let port_offset = 5 + domain_len;
let port = ((buf[port_offset] as u16) << 8) | (buf[port_offset + 1] as u16);
let target = format!("127.0.0.1:{port}");
let mut upstream = tokio::net::TcpStream::connect(target).await.unwrap();
client
.write_all(&[0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
.await
.unwrap();
let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream).await;
});
}
});
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(aioduct::ProxyConfig::socks5h(&format!("socks5h://{socks_addr}")).unwrap())
.build()
.unwrap();
let resp = client
.get(&format!("http://localhost:{}/", target_addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
#[ignore = "needs CONNECT tunnel rewrite: proxy and target must be separate servers"]
#[tokio::test]
async fn test_http_proxy_basic_auth() {
let auth_seen = Arc::new(AtomicBool::new(false));
let auth_seen_clone = auth_seen.clone();
let (proxy_addr, _counter) = h1_server_with(move |req| {
let auth_seen = auth_seen_clone.clone();
async move {
if let Some(auth) = req.headers().get("proxy-authorization") {
let auth_str = auth.to_str().unwrap_or("");
if auth_str == "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" {
auth_seen.store(true, AtomicOrdering::SeqCst);
}
}
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("ok"))))
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(
aioduct::ProxyConfig::http(&format!("http://{proxy_addr}"))
.unwrap()
.basic_auth("Aladdin", "open sesame"),
)
.build()
.unwrap();
let resp = client
.get("http://example.com/prox")
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert!(
auth_seen.load(AtomicOrdering::SeqCst),
"proxy should have received Proxy-Authorization header with basic auth"
);
}
#[tokio::test]
async fn test_http_proxy_preserves_host_header() {
let (target_addr, _counter) = h1_server_with(|req| async move {
let host = req
.headers()
.get("host")
.map(|v| v.to_str().unwrap_or("").to_owned())
.unwrap_or_default();
let method = req.method().to_string();
let body = format!("method={method} host={host}");
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(body))))
})
.await;
let (proxy_addr, _conns) = connect_proxy().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap())
.build()
.unwrap();
let resp = client
.get(&format!("http://{target_addr}/path"))
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(body.contains("host="), "expected host in body, got: {body}");
}
#[tokio::test]
async fn test_connect_tunnel_includes_proxy_auth() {
let auth_seen = Arc::new(AtomicBool::new(false));
let auth_seen_clone = auth_seen.clone();
let proxy_addr = raw_server(move |req_bytes| {
let auth_seen = auth_seen_clone.clone();
async move {
let req_str = String::from_utf8_lossy(&req_bytes);
if req_str.starts_with("CONNECT") {
for line in req_str.lines() {
if line.to_lowercase().starts_with("proxy-authorization:") {
let value = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
if value == "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" {
auth_seen.store(true, AtomicOrdering::SeqCst);
}
}
}
}
b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n".to_vec()
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(
aioduct::ProxyConfig::http(&format!("http://{proxy_addr}"))
.unwrap()
.basic_auth("Aladdin", "open sesame"),
)
.build()
.unwrap();
let result = client
.get("https://hyper.rs.local/prox")
.unwrap()
.send()
.await;
assert!(result.is_err(), "expected tunnel error, got success");
assert!(
auth_seen.load(AtomicOrdering::SeqCst),
"CONNECT request should include Proxy-Authorization header"
);
}
#[tokio::test]
async fn test_connect_tunnel_detects_auth_required() {
let proxy_addr = raw_server(|req_bytes| async move {
let req_str = String::from_utf8_lossy(&req_bytes);
if req_str.starts_with("CONNECT") {
b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n".to_vec()
} else {
b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n".to_vec()
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap())
.build()
.unwrap();
let err = client
.get("https://hyper.rs.local/prox")
.unwrap()
.send()
.await;
assert!(err.is_err(), "expected error from 407 proxy response");
let err_msg = format!("{}", err.unwrap_err());
assert!(
err_msg.contains("407") || err_msg.contains("CONNECT tunnel failed"),
"expected tunnel failure message, got: {err_msg}"
);
}
#[ignore = "needs CONNECT tunnel rewrite: proxy and target must be separate servers"]
#[tokio::test]
async fn test_proxy_settings_routes_http_and_https_separately() {
let (http_proxy_addr, _counter) = h1_server_with(|req| async move {
let uri = req.uri().to_string();
let body = format!("http-proxy: {uri}");
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(body))))
})
.await;
let (target_addr, _counter) = h1_server_with(|_req| async move {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("direct"))))
})
.await;
let settings = aioduct::ProxySettings::default()
.http(aioduct::ProxyConfig::http(&format!("http://{http_proxy_addr}")).unwrap())
.no_proxy(aioduct::NoProxy::new(&format!("{}", target_addr.ip())));
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy_settings(settings)
.build()
.unwrap();
let resp = client
.get("http://example.com/test")
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.starts_with("http-proxy:"),
"expected http-proxy response, got: {body}"
);
let resp = client
.get(&format!("http://{target_addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "direct");
}
#[tokio::test]
async fn test_connect_tunnel_target_authority() {
let connect_target = Arc::new(std::sync::Mutex::new(String::new()));
let connect_target_clone = connect_target.clone();
let proxy_addr = raw_server(move |req_bytes| {
let connect_target = connect_target_clone.clone();
async move {
let req_str = String::from_utf8_lossy(&req_bytes);
if req_str.starts_with("CONNECT") {
if let Some(target) = req_str.split_whitespace().nth(1) {
*connect_target.lock().unwrap() = target.to_string();
}
}
b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n".to_vec()
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap())
.build()
.unwrap();
let _ = client
.get("https://hyper.rs.local:8443/path")
.unwrap()
.send()
.await;
let target = connect_target.lock().unwrap().clone();
assert_eq!(
target, "hyper.rs.local:8443",
"CONNECT should target the original host:port"
);
}
#[tokio::test]
async fn test_connect_tunnel_default_port() {
let connect_target = Arc::new(std::sync::Mutex::new(String::new()));
let connect_target_clone = connect_target.clone();
let proxy_addr = raw_server(move |req_bytes| {
let connect_target = connect_target_clone.clone();
async move {
let req_str = String::from_utf8_lossy(&req_bytes);
if req_str.starts_with("CONNECT")
&& let Some(target) = req_str.split_whitespace().nth(1)
{
*connect_target.lock().unwrap() = target.to_string();
}
b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n".to_vec()
}
})
.await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap())
.build()
.unwrap();
let _ = client
.get("https://hyper.rs.local/path")
.unwrap()
.send()
.await;
let target = connect_target.lock().unwrap().clone();
assert_eq!(
target, "hyper.rs.local:443",
"CONNECT should include port 443 for HTTPS when not explicit in the URL"
);
}
#[test]
fn test_socks5h_constructor() {
assert!(
aioduct::ProxyConfig::socks5h("socks5h://proxy.example.com:1080").is_ok(),
"socks5h:// should be accepted"
);
}
#[test]
fn test_socks5h_constructor_rejects_wrong_scheme() {
assert!(aioduct::ProxyConfig::socks5h("socks5://proxy.example.com:1080").is_err());
assert!(aioduct::ProxyConfig::socks5h("http://proxy.example.com:1080").is_err());
}
#[test]
fn test_https_proxy_constructor() {
assert!(
aioduct::ProxyConfig::https("https://proxy.example.com:443").is_ok(),
"https:// should be accepted"
);
}
#[test]
fn test_https_proxy_constructor_rejects_wrong_scheme() {
assert!(aioduct::ProxyConfig::https("http://proxy.example.com:443").is_err());
assert!(aioduct::ProxyConfig::https("socks5://proxy.example.com:443").is_err());
}
#[test]
fn test_socks5_constructor_without_port() {
assert!(
aioduct::ProxyConfig::socks5("socks5://proxy.example.com").is_ok(),
"socks5:// without port should be accepted"
);
}
#[test]
fn test_https_proxy_constructor_without_port() {
assert!(
aioduct::ProxyConfig::https("https://proxy.example.com").is_ok(),
"https:// without port should be accepted"
);
}
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[tokio::test]
async fn system_proxy_integration() {
let connect_seen = Arc::new(AtomicBool::new(false));
let connect_seen_clone = connect_seen.clone();
let proxy_addr = raw_server(move |req_bytes| {
let connect_seen = connect_seen_clone.clone();
async move {
let req_str = String::from_utf8_lossy(&req_bytes);
if req_str.starts_with("CONNECT") {
connect_seen.store(true, AtomicOrdering::SeqCst);
}
b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n".to_vec()
}
})
.await;
let proxy_url = format!("http://{proxy_addr}");
{
let _guard = ENV_MUTEX.lock().unwrap();
unsafe {
std::env::set_var("HTTP_PROXY", &proxy_url);
std::env::set_var("HTTPS_PROXY", &proxy_url);
std::env::remove_var("NO_PROXY");
std::env::remove_var("no_proxy");
}
}
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.system_proxy()
.build()
.unwrap();
let result = client
.get("https://hyper.rs.local/prox")
.unwrap()
.send()
.await;
{
let _guard = ENV_MUTEX.lock().unwrap();
unsafe {
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!(
connect_seen.load(AtomicOrdering::SeqCst),
"system_proxy should route HTTPS request through proxy CONNECT"
);
assert!(result.is_err(), "expected tunnel to fail with 400");
}
#[ignore = "needs CONNECT tunnel rewrite: proxy and target must be separate servers"]
#[tokio::test]
async fn proxy_chain_integration() {
let (target_addr, _) = h1_server_with(|_req| async move {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("target-reached"))))
})
.await;
let (proxy_addr, _) = h1_server_with(|req| async move {
let uri = req.uri().to_string();
let body = format!("via-chain: {uri}");
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(body))))
})
.await;
let chain = aioduct::ProxyChain::single(
aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap(),
);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy_chain(chain)
.build()
.unwrap();
let resp = client
.get(&format!("http://{target_addr}/"))
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("via-chain:"),
"expected chain proxy response, got: {body}"
);
}
#[tokio::test]
async fn no_proxy_cidr_integration() {
let (target_addr, _) = h1_server_with(|_req| async move {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("direct"))))
})
.await;
let (proxy_addr, _) = h1_server_with(|req| async move {
let uri = req.uri().to_string();
let body = format!("proxied: {uri}");
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(body))))
})
.await;
let settings = aioduct::ProxySettings::all(
aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap(),
)
.no_proxy(aioduct::NoProxy::new("127.0.0.0/8"));
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy_settings(settings)
.build()
.unwrap();
let resp = client
.get(&format!("http://{target_addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "direct");
}
#[ignore = "needs CONNECT tunnel rewrite: proxy and target must be separate servers"]
#[tokio::test]
async fn no_proxy_port_specific() {
let (target_addr, _) = h1_server_with(|_req| async move {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("direct"))))
})
.await;
let (proxy_addr, _) = h1_server_with(|req| async move {
let uri = req.uri().to_string();
let body = format!("proxied: {uri}");
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(body))))
})
.await;
let target_ip = target_addr.ip().to_string();
let non_matching_port = target_addr.port() + 1;
let no_proxy_rule = format!("{target_ip}:{non_matching_port}");
let settings = aioduct::ProxySettings::all(
aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap(),
)
.no_proxy(aioduct::NoProxy::new(&no_proxy_rule));
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy_settings(settings)
.build()
.unwrap();
let resp = client
.get(&format!("http://{target_addr}/"))
.unwrap()
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("proxied:"),
"expected proxied (port mismatch), got: {body}"
);
}
#[tokio::test]
async fn proxy_failure_dns() {
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(
aioduct::ProxyConfig::http("http://this.hostname.does.not.exist.invalid:80").unwrap(),
)
.build()
.unwrap();
let err = client
.get("http://example.com/path")
.unwrap()
.send()
.await
.unwrap_err();
assert!(
err.is_dns(),
"expected DNS error, got: {err} (is_dns={})",
err.is_dns()
);
}
#[tokio::test]
async fn proxy_failure_connection_refused() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(aioduct::ProxyConfig::http(&format!("http://{addr}")).unwrap())
.build()
.unwrap();
let err = client
.get("http://example.com/path")
.unwrap()
.send()
.await
.unwrap_err();
assert!(
err.is_connect(),
"expected connect error, got: {err} (is_connect={})",
err.is_connect()
);
}
#[tokio::test]
async fn proxy_settings_custom_with_no_proxy_precedence() {
let (proxy_addr, _counter) = h1_server_with(|_req| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("proxied"))))
})
.await;
let (target_addr, _counter) = h1_server().await;
let called = Arc::new(AtomicBool::new(false));
let called2 = called.clone();
let settings = aioduct::ProxySettings::default()
.custom(move |_url| {
called2.store(true, AtomicOrdering::SeqCst);
Some(aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap())
})
.no_proxy(aioduct::NoProxy::new("127.0.0.1"));
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy_settings(settings)
.build()
.unwrap();
let resp = client
.get(&format!("http://{target_addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let body = resp.text().await.unwrap();
assert_eq!(body, "hello aioduct");
assert!(!called.load(AtomicOrdering::SeqCst));
}
#[tokio::test]
async fn proxy_with_redirect_routing() {
let (target_addr, _counter) = h1_server().await;
let (redirect_addr, _counter) = h1_server_with(move |_req| {
let target = format!("http://{target_addr}/");
async move {
Ok::<_, Infallible>(
Response::builder()
.status(302)
.header("location", target)
.body(Full::new(Bytes::new()))
.unwrap(),
)
}
})
.await;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_addr = listener.local_addr().unwrap();
let proxy_req_count = Arc::new(AtomicUsize::new(0));
let prc = Arc::clone(&proxy_req_count);
tokio::spawn(async move {
loop {
let (mut stream, _) = match listener.accept().await {
Ok(c) => c,
Err(_) => return,
};
prc.fetch_add(1, AtomicOrdering::SeqCst);
let mut buf = vec![0u8; 4096];
let _ = stream.read(&mut buf).await;
let target = format!("http://{target_addr}/");
let resp =
format!("HTTP/1.1 302 Found\r\nlocation: {target}\r\nContent-Length: 0\r\n\r\n");
stream.write_all(resp.as_bytes()).await.ok();
}
});
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap())
.build()
.unwrap();
let result = client
.get(&format!("http://{redirect_addr}/start"))
.unwrap()
.send()
.await;
assert!(
result.is_err(),
"redirect loop should exhaust max_redirects"
);
let count = proxy_req_count.load(AtomicOrdering::SeqCst);
assert!(
count >= 1,
"proxy should see the initial request, got {count}"
);
}
#[tokio::test]
async fn credential_resolver_global_env() {
use aioduct::{CredentialResolver, EnvCredentialResolver};
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = ENV_MUTEX.lock().unwrap();
unsafe {
std::env::remove_var("AIODUCT_PROXY_USER");
std::env::remove_var("AIODUCT_PROXY_PASS");
}
let resolver = EnvCredentialResolver;
let result = resolver.resolve("any-key");
assert!(result.is_none());
}
#[ignore = "needs CONNECT tunnel rewrite: proxy and target must be separate servers"]
#[tokio::test]
async fn proxy_connection_pooling_with_counter() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_addr = listener.local_addr().unwrap();
let conn_count = Arc::new(AtomicUsize::new(0));
let cc = Arc::clone(&conn_count);
tokio::spawn(async move {
loop {
let (mut stream, _) = match listener.accept().await {
Ok(c) => c,
Err(_) => return,
};
cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
loop {
let mut buf = vec![0u8; 4096];
let n = match stream.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
if buf[..n].windows(4).any(|w| w == b"\r\n\r\n") {
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\nproxied")
.await
.unwrap();
stream.flush().await.unwrap();
}
}
}
});
let (target_addr, _counter) = h1_server().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap())
.pool_idle_timeout(std::time::Duration::from_secs(60))
.pool_max_idle_per_host(5)
.build()
.unwrap();
for _ in 0..2 {
let resp = client
.get(&format!("http://{target_addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
let _ = resp.bytes().await.unwrap();
}
let count = conn_count.load(std::sync::atomic::Ordering::SeqCst);
assert!(
count >= 1,
"expected at least 1 proxy connection, got {count}"
);
}
#[tokio::test]
async fn proxy_failure_reset_deterministic() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
drop(stream);
});
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(aioduct::ProxyConfig::http(&format!("http://{addr}")).unwrap())
.build()
.unwrap();
let result = client.get("http://example.com/path").unwrap().send().await;
assert!(result.is_err(), "proxy reset should produce an error");
}
async fn connect_proxy() -> (std::net::SocketAddr, Arc<AtomicUsize>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let conn_count = Arc::new(AtomicUsize::new(0));
let cc = Arc::clone(&conn_count);
tokio::spawn(async move {
loop {
let (mut client, _) = match listener.accept().await {
Ok(c) => c,
Err(_) => return,
};
cc.fetch_add(1, AtomicOrdering::SeqCst);
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
let n = match client.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
let head = String::from_utf8_lossy(&buf[..n]);
if !head.starts_with("CONNECT ") {
return;
}
let target = head.split_whitespace().nth(1).unwrap_or("");
client
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
.await
.unwrap();
let mut target = TcpStream::connect(target).await.unwrap();
let _ = tokio::io::copy_bidirectional(&mut client, &mut target).await;
});
}
});
(addr, conn_count)
}
#[tokio::test]
async fn http_proxy_uses_connect_tunnel() {
let (target_addr, _counter) = h1_server().await;
let (proxy_addr, conns) = connect_proxy().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap())
.build()
.unwrap();
let resp = client
.get(&format!("http://{target_addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
assert!(conns.load(AtomicOrdering::SeqCst) >= 1);
}
#[tokio::test]
async fn connect_tunnel_pooled_reuse() {
let (target_addr, _counter) = h1_server().await;
let (proxy_addr, conns) = connect_proxy().await;
let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
.proxy(aioduct::ProxyConfig::http(&format!("http://{proxy_addr}")).unwrap())
.pool_idle_timeout(std::time::Duration::from_secs(60))
.pool_max_idle_per_host(5)
.build()
.unwrap();
for _ in 0..2 {
let resp = client
.get(&format!("http://{target_addr}/"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
}
assert!(conns.load(AtomicOrdering::SeqCst) >= 1);
}
#[cfg(feature = "rustls")]
async fn tls_connect_proxy() -> (
std::net::SocketAddr,
rustls::pki_types::CertificateDer<'static>,
Arc<AtomicUsize>,
) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
aioduct_test_server::tls::install_crypto_provider();
let cert = aioduct_test_server::tls::generate_self_signed(&["localhost"]);
let cert_der = cert.cert_der.clone();
let server_config =
rustls::ServerConfig::builder_with_provider(aioduct_test_server::tls::crypto_provider())
.with_safe_default_protocol_versions()
.expect("configured rustls provider does not support the default TLS versions")
.with_no_client_auth()
.with_single_cert(vec![cert.cert_der.clone()], cert.key_der.clone_key())
.unwrap();
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config));
let listener = TcpListener::bind("localhost:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let conn_count = Arc::new(AtomicUsize::new(0));
let cc = Arc::clone(&conn_count);
tokio::spawn(async move {
loop {
let (tcp, _) = match listener.accept().await {
Ok(c) => c,
Err(_) => return,
};
cc.fetch_add(1, AtomicOrdering::SeqCst);
let acceptor = acceptor.clone();
tokio::spawn(async move {
let mut client = match acceptor.accept(tcp).await {
Ok(s) => s,
Err(_) => return,
};
let mut buf = vec![0u8; 8192];
let n = match client.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
let head = String::from_utf8_lossy(&buf[..n]);
if !head.starts_with("CONNECT ") {
return;
}
let target = head.split_whitespace().nth(1).unwrap_or("").to_owned();
client
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
.await
.unwrap();
let mut upstream = match TcpStream::connect(&target).await {
Ok(s) => s,
Err(_) => return,
};
let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream).await;
});
}
});
(addr, cert_der, conn_count)
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn https_proxy_tls_to_proxy_reaches_http_target() {
let (target_addr, _counter) = h1_server().await;
let (proxy_addr, proxy_cert, conns) = tls_connect_proxy().await;
let client_config = aioduct_test_server::tls::make_client_config(&proxy_cert);
let connector = aioduct::tls::RustlsConnector::new(client_config);
let client: HttpEngineSend<TokioRuntime, TcpConnector> = HttpEngineSend::builder()
.tls(connector)
.proxy(
aioduct::ProxyConfig::https(&format!("https://localhost:{}", proxy_addr.port()))
.unwrap(),
)
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("http://{target_addr}/path"))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello aioduct");
assert!(
conns.load(AtomicOrdering::SeqCst) >= 1,
"the TLS proxy should have accepted at least one connection"
);
}
#[cfg(feature = "rustls")]
fn client_config_trusting(
certs: &[rustls::pki_types::CertificateDer<'static>],
) -> std::sync::Arc<rustls::ClientConfig> {
let mut root_store = rustls::RootCertStore::empty();
for cert in certs {
root_store.add(cert.clone()).unwrap();
}
let mut config =
rustls::ClientConfig::builder_with_provider(aioduct_test_server::tls::crypto_provider())
.with_safe_default_protocol_versions()
.expect("configured rustls provider does not support the default TLS versions")
.with_root_certificates(root_store)
.with_no_client_auth();
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
std::sync::Arc::new(config)
}
#[cfg(feature = "rustls")]
#[tokio::test]
async fn https_proxy_tls_to_proxy_reaches_https_target() {
let (origin_addr, origin_cert, _origin_counter) =
aioduct_test_server::tls::tls_h1_server(&[b"http/1.1"]).await;
let (proxy_addr, proxy_cert, conns) = tls_connect_proxy().await;
let client_config = client_config_trusting(&[proxy_cert, origin_cert]);
let connector = aioduct::tls::RustlsConnector::new(client_config);
let client: HttpEngineSend<TokioRuntime, TcpConnector> = HttpEngineSend::builder()
.tls(connector)
.proxy(
aioduct::ProxyConfig::https(&format!("https://localhost:{}", proxy_addr.port()))
.unwrap(),
)
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
let resp = client
.get(&format!("https://localhost:{}/", origin_addr.port()))
.unwrap()
.send()
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(resp.text().await.unwrap(), "hello tls");
assert!(
conns.load(AtomicOrdering::SeqCst) >= 1,
"the TLS proxy should have accepted at least one connection"
);
}