use base64::Engine as _;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
};
pub const ENV_VARS: [&str; 4] = ["http_proxy", "https_proxy", "all_proxy", "no_proxy"];
#[derive(Debug, Clone, PartialEq, Eq)]
struct NoProxyEntry {
host: String,
port: Option<u16>,
}
#[derive(Debug, Clone, Default)]
pub struct NoProxyList {
all: bool,
entries: Vec<NoProxyEntry>,
}
impl NoProxyList {
pub fn parse(list: &str) -> Self {
let trimmed = list.trim();
if trimmed.is_empty() {
return Self::default();
}
if trimmed == "*" {
return Self {
all: true,
entries: Vec::new(),
};
}
Self {
all: false,
entries: trimmed
.split([',', ' '])
.filter_map(parse_no_proxy_entry)
.collect(),
}
}
pub fn hits(&self, host: &str, port: u16) -> bool {
if self.all {
return true;
}
let target = strip_brackets(host).to_ascii_lowercase();
self.entries.iter().any(|e| {
if e.port.is_some_and(|p| p != port) {
return false;
}
let mut domain = e.host.as_str();
if let Some(rest) = domain.strip_prefix("*.") {
domain = rest;
} else if let Some(rest) = domain.strip_prefix('.') {
domain = rest;
} else if let Some(rest) = domain.strip_prefix('*') {
domain = rest;
}
if domain.is_empty() {
return false;
}
target == domain || target.ends_with(&format!(".{}", domain))
})
}
}
fn strip_brackets(host: &str) -> &str {
host.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host)
}
fn parse_no_proxy_entry(entry: &str) -> Option<NoProxyEntry> {
let trimmed = entry.trim().to_ascii_lowercase();
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with('[') {
let closing = trimmed.find(']')?;
let host = strip_brackets(&trimmed).to_string();
let rest = &trimmed[closing + 1..];
if let Some(port_str) = rest.strip_prefix(':') {
return Some(NoProxyEntry {
host,
port: parse_port(port_str),
});
}
return Some(NoProxyEntry { host, port: None });
}
if trimmed.split(':').count() > 2 {
return Some(NoProxyEntry {
host: trimmed,
port: None,
});
}
if let Some((host, port_str)) = trimmed.split_once(':') {
return Some(match parse_port(port_str) {
Some(port) => NoProxyEntry {
host: host.to_string(),
port: Some(port),
},
None => NoProxyEntry {
host: trimmed,
port: None,
},
});
}
Some(NoProxyEntry {
host: trimmed,
port: None,
})
}
fn parse_port(s: &str) -> Option<u16> {
s.parse::<u16>().ok().filter(|&p| p != 0)
}
pub fn env_flags() -> Vec<(&'static str, bool)> {
ENV_VARS
.iter()
.map(|name| (*name, env_var_ci(name).is_some()))
.collect()
}
pub fn env_var_ci(key: &str) -> Option<String> {
let lower = key.to_ascii_lowercase();
let upper = key.to_ascii_uppercase();
for name in [lower, upper] {
if let Ok(v) = std::env::var(name) {
let t = v.trim();
if !t.is_empty() {
return Some(t.to_string());
}
}
}
None
}
fn qualify_proxy_url(proxy: &str, scheme: &str) -> Option<String> {
let candidate = if proxy.contains("://") {
proxy.to_string()
} else {
format!("{}://{}", scheme, proxy)
};
match reqwest::Url::parse(&candidate) {
Ok(url)
if matches!(
url.scheme(),
"http" | "https" | "socks4" | "socks4a" | "socks5" | "socks5h"
) =>
{
Some(candidate)
}
_ => None,
}
}
pub fn proxy_from_env() -> Option<reqwest::Proxy> {
let no_proxy = NoProxyList::parse(&env_var_ci("no_proxy").unwrap_or_default());
let http = env_var_ci("http_proxy")
.or_else(|| env_var_ci("all_proxy"))
.and_then(|v| qualify_proxy_url(&v, "http"));
let https = env_var_ci("https_proxy")
.or_else(|| env_var_ci("all_proxy"))
.and_then(|v| qualify_proxy_url(&v, "https"));
if http.is_none() && https.is_none() {
return None;
}
Some(reqwest::Proxy::custom(
move |url: &reqwest::Url| -> Option<String> {
let scheme = match url.scheme() {
"http" => "http",
"https" => "https",
_ => return None,
};
let host = url.host_str()?;
let port = url.port_or_known_default()?;
if no_proxy.hits(host, port) {
return None;
}
match scheme {
"https" => https.clone(),
_ => http.clone(),
}
},
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProxyKind {
Socks5,
Http,
}
#[derive(Debug, Clone)]
pub struct Proxy {
pub kind: ProxyKind,
pub host: String,
pub port: u16,
pub auth: Option<(String, String)>,
}
pub fn from_url(url: Option<&str>) -> Result<Option<Proxy>, String> {
let Some(url) = url.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(None);
};
parse(url)
.map(Some)
.ok_or_else(|| format!("invalid proxy url: {url}"))
}
pub fn parse(url: &str) -> Option<Proxy> {
let (scheme, rest) = url.split_once("://").unwrap_or(("socks5", url));
let kind = match scheme.to_ascii_lowercase().as_str() {
"socks5" | "socks5h" | "socks" => ProxyKind::Socks5,
"http" | "https" => ProxyKind::Http,
_ => return None,
};
let (auth, hostport) = match rest.rsplit_once('@') {
Some((userinfo, hp)) => {
let (u, p) = userinfo.split_once(':').unwrap_or((userinfo, ""));
(Some((u.to_string(), p.to_string())), hp)
}
None => (None, rest),
};
let hostport = hostport.trim_end_matches('/');
let (host, port) = hostport.rsplit_once(':')?;
if host.is_empty() {
return None;
}
Some(Proxy {
kind,
host: strip_brackets(host).to_string(),
port: port.parse().ok()?,
auth,
})
}
impl Proxy {
pub fn describe(&self) -> String {
let scheme = match self.kind {
ProxyKind::Socks5 => "socks5",
ProxyKind::Http => "http",
};
format!("{scheme}://{}:{}", self.host, self.port)
}
pub async fn connect(&self, target_host: &str, target_port: u16) -> std::io::Result<TcpStream> {
let mut stream = TcpStream::connect((self.host.as_str(), self.port)).await?;
match self.kind {
ProxyKind::Socks5 => {
socks5_handshake(&mut stream, self, target_host, target_port).await?
}
ProxyKind::Http => http_connect(&mut stream, self, target_host, target_port).await?,
}
Ok(stream)
}
}
fn invalid(msg: &str) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string())
}
async fn socks5_handshake(
stream: &mut TcpStream,
proxy: &Proxy,
target_host: &str,
target_port: u16,
) -> std::io::Result<()> {
let methods: &[u8] = if proxy.auth.is_some() {
&[0x00, 0x02]
} else {
&[0x00]
};
let mut greeting = vec![0x05u8, methods.len() as u8];
greeting.extend_from_slice(methods);
stream.write_all(&greeting).await?;
let mut reply = [0u8; 2];
stream.read_exact(&mut reply).await?;
if reply[0] != 0x05 {
return Err(invalid("proxy is not SOCKS5"));
}
match reply[1] {
0x00 => {}
0x02 => {
let Some((user, pass)) = &proxy.auth else {
return Err(invalid("proxy requires authentication"));
};
let mut auth = vec![0x01u8, user.len().min(255) as u8];
auth.extend_from_slice(&user.as_bytes()[..user.len().min(255)]);
auth.push(pass.len().min(255) as u8);
auth.extend_from_slice(&pass.as_bytes()[..pass.len().min(255)]);
stream.write_all(&auth).await?;
let mut status = [0u8; 2];
stream.read_exact(&mut status).await?;
if status[1] != 0x00 {
return Err(invalid("proxy rejected credentials"));
}
}
0xff => return Err(invalid("proxy accepted no auth method")),
other => return Err(invalid(&format!("proxy chose unsupported auth {other}"))),
}
let host_bytes = target_host.as_bytes();
if host_bytes.len() > 255 {
return Err(invalid("target hostname too long"));
}
let mut request = vec![0x05u8, 0x01, 0x00, 0x03, host_bytes.len() as u8];
request.extend_from_slice(host_bytes);
request.extend_from_slice(&target_port.to_be_bytes());
stream.write_all(&request).await?;
let mut head = [0u8; 4];
stream.read_exact(&mut head).await?;
if head[0] != 0x05 {
return Err(invalid("bad SOCKS5 reply version"));
}
if head[1] != 0x00 {
return Err(invalid(&format!(
"SOCKS5 connect failed (code {})",
head[1]
)));
}
match head[3] {
0x01 => {
let mut skip = [0u8; 4 + 2];
stream.read_exact(&mut skip).await?;
}
0x03 => {
let mut len = [0u8; 1];
stream.read_exact(&mut len).await?;
let mut skip = vec![0u8; len[0] as usize + 2];
stream.read_exact(&mut skip).await?;
}
0x04 => {
let mut skip = [0u8; 16 + 2];
stream.read_exact(&mut skip).await?;
}
_ => return Err(invalid("bad SOCKS5 bind address type")),
}
Ok(())
}
async fn http_connect(
stream: &mut TcpStream,
proxy: &Proxy,
target_host: &str,
target_port: u16,
) -> std::io::Result<()> {
let mut request = format!(
"CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n"
);
if let Some((user, pass)) = &proxy.auth {
let token =
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}").as_bytes());
request.push_str(&format!("Proxy-Authorization: Basic {token}\r\n"));
}
request.push_str("Proxy-Connection: keep-alive\r\n\r\n");
stream.write_all(request.as_bytes()).await?;
let mut buf = Vec::with_capacity(256);
let mut byte = [0u8; 1];
loop {
if stream.read(&mut byte).await? == 0 {
return Err(invalid("proxy closed during CONNECT"));
}
buf.push(byte[0]);
if buf.ends_with(b"\r\n\r\n") {
break;
}
if buf.len() > 8192 {
return Err(invalid("proxy CONNECT response too large"));
}
}
let head = String::from_utf8_lossy(&buf);
let status = head.lines().next().unwrap_or("");
let ok = status.split_whitespace().nth(1) == Some("200");
if !ok {
return Err(invalid(&format!(
"proxy CONNECT rejected: {}",
status.trim()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_proxy_urls() {
let p = parse("socks5://127.0.0.1:1080").unwrap();
assert_eq!(p.kind, ProxyKind::Socks5);
assert_eq!(p.port, 1080);
assert!(p.auth.is_none());
let p = parse("http://u:p@proxy:3128").unwrap();
assert_eq!(p.kind, ProxyKind::Http);
assert_eq!(p.auth.as_ref().unwrap().0, "u");
assert!(parse("ftp://x:1").is_none());
assert!(parse("socks5://hostonly").is_none());
}
#[test]
fn from_url_treats_empty_as_direct() {
assert!(from_url(None).unwrap().is_none());
assert!(from_url(Some(" ")).unwrap().is_none());
assert_eq!(
from_url(Some("http://u:p@proxy:3128"))
.unwrap()
.unwrap()
.port,
3128
);
assert!(
from_url(Some("ftp://x:1")).is_err(),
"写错的代理不应静默直连"
);
}
#[test]
fn no_proxy_respects_exclusions() {
let list = NoProxyList::parse("bedrock-runtime.us-east-1.amazonaws.com");
assert!(list.hits("bedrock-runtime.us-east-1.amazonaws.com", 443));
assert!(!list.hits("other.example.com", 443));
}
#[test]
fn no_proxy_wildcards_ipv6_ports() {
let list = NoProxyList::parse(
"example.com, .wildcard.org, *.star.net, ::1, [2001:db8::1], 127.0.0.1:8080",
);
assert!(list.hits("example.com", 443));
assert!(list.hits("api.example.com", 443));
assert!(!list.hits("notexample.com", 443));
assert!(list.hits("wildcard.org", 443));
assert!(list.hits("api.wildcard.org", 443));
assert!(list.hits("star.net", 443));
assert!(list.hits("api.star.net", 443));
assert!(list.hits("::1", 80));
assert!(list.hits("2001:db8::1", 443));
assert!(list.hits("127.0.0.1", 8080));
assert!(!list.hits("127.0.0.1", 3000));
}
#[test]
fn no_proxy_entry_port_limits() {
let list = NoProxyList::parse("127.0.0.1:8080");
assert!(list.hits("127.0.0.1", 8080));
assert!(!list.hits("127.0.0.1", 80));
assert!(!list.hits("127.0.0.1", 3000));
let bare = NoProxyList::parse("example.com");
assert!(bare.hits("example.com", 80));
assert!(bare.hits("example.com", 443));
}
#[test]
fn no_proxy_case_insensitive() {
assert!(NoProxyList::parse("EXAMPLE.COM").hits("api.example.com", 443));
assert!(NoProxyList::parse("Example.Com").hits("API.EXAMPLE.COM", 443));
}
#[test]
fn no_proxy_star_and_empty() {
assert!(NoProxyList::parse("*").hits("anything.example.com", 443));
assert!(!NoProxyList::parse("").hits("x.com", 443));
assert!(!NoProxyList::default().hits("x.com", 443));
assert!(NoProxyList::parse("a.com b.com").hits("b.com", 443));
}
#[test]
fn env_flags_cover_every_decision_var() {
let flags = env_flags();
assert_eq!(flags.len(), ENV_VARS.len());
let names: Vec<&str> = flags.iter().map(|(name, _)| *name).collect();
assert_eq!(names, ENV_VARS.to_vec());
}
}