use crate::BoxError;
use base64::Engine as _;
#[derive(Clone, Debug)]
pub enum ProxyConfig {
Direct,
HttpConnect {
host: String,
port: u16,
tls: bool,
basic_auth: Option<String>,
},
Socks5 {
host: String,
port: u16,
auth: Option<(String, String)>,
remote_dns: bool,
},
}
impl ProxyConfig {
pub fn from_env(target_host: &str) -> Result<Self, BoxError> {
if no_proxy_matches(target_host) {
return Ok(Self::Direct);
}
match env_var(&["https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"]) {
Some(input) => Self::parse(&input),
None => Ok(Self::Direct),
}
}
pub fn parse(input: &str) -> Result<Self, BoxError> {
let url = if input.contains("://") {
url::Url::parse(input)
} else {
url::Url::parse(&format!("http://{}", input))
}
.map_err(|err| format!("invalid proxy URL `{}`: {}", input, err))?;
let host = url
.host_str()
.ok_or_else(|| format!("proxy URL `{}` is missing a host", input))?
.to_string();
let username = url.username();
let password = url.password();
match url.scheme() {
"http" | "https" => {
let tls = url.scheme() == "https";
let port = url.port().unwrap_or(if tls { 443 } else { 80 });
let basic_auth = if !username.is_empty() || password.is_some() {
let credentials = format!("{}:{}", username, password.unwrap_or_default());
Some(base64::engine::general_purpose::STANDARD.encode(credentials))
} else {
None
};
Ok(Self::HttpConnect {
host,
port,
tls,
basic_auth,
})
},
"socks5" | "socks5h" => {
let port = url.port().unwrap_or(1080);
let auth = if !username.is_empty() {
Some((
username.to_string(),
password.unwrap_or_default().to_string(),
))
} else {
None
};
Ok(Self::Socks5 {
host,
port,
auth,
remote_dns: url.scheme() == "socks5h",
})
},
scheme => Err(format!("unsupported proxy scheme: {}", scheme).into()),
}
}
}
fn env_var(names: &[&str]) -> Option<String> {
names
.iter()
.find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty()))
}
fn no_proxy_matches(host: &str) -> bool {
let Some(no_proxy) = env_var(&["no_proxy", "NO_PROXY"]) else {
return false;
};
no_proxy_list_matches(&no_proxy, host)
}
fn no_proxy_list_matches(no_proxy: &str, host: &str) -> bool {
no_proxy.split(',').any(|entry| {
let entry = entry.trim().trim_start_matches('.');
!entry.is_empty()
&& (entry == "*"
|| host.eq_ignore_ascii_case(entry)
|| (host.len() > entry.len()
&& host[..host.len() - entry.len()].ends_with('.')
&& host[host.len() - entry.len()..].eq_ignore_ascii_case(entry)))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_http_proxy() {
let config = ProxyConfig::parse("http://proxy.example:3128").unwrap();
let ProxyConfig::HttpConnect {
host,
port,
tls,
basic_auth,
} = config
else {
panic!("expected HttpConnect, got: {:?}", config)
};
assert_eq!(host, "proxy.example");
assert_eq!(port, 3128);
assert!(!tls);
assert!(basic_auth.is_none());
}
#[test]
fn parse_bare_host_port_as_http_proxy() {
let config = ProxyConfig::parse("proxy.example:8080").unwrap();
assert!(matches!(
config,
ProxyConfig::HttpConnect {
tls: false,
port: 8080,
..
}
));
}
#[test]
fn parse_https_proxy_with_auth() {
let config = ProxyConfig::parse("https://user:pass@proxy.example").unwrap();
let ProxyConfig::HttpConnect {
host,
port,
tls,
basic_auth,
} = config
else {
panic!("expected HttpConnect, got: {:?}", config)
};
assert_eq!(host, "proxy.example");
assert_eq!(port, 443);
assert!(tls);
assert_eq!(basic_auth.as_deref(), Some("dXNlcjpwYXNz")); }
#[test]
fn parse_socks5_proxy() {
let config = ProxyConfig::parse("socks5://127.0.0.1").unwrap();
assert!(matches!(
config,
ProxyConfig::Socks5 {
port: 1080,
remote_dns: false,
auth: None,
..
}
));
let config = ProxyConfig::parse("socks5h://user:pass@127.0.0.1:9050").unwrap();
let ProxyConfig::Socks5 {
port,
remote_dns,
auth,
..
} = config
else {
panic!("expected Socks5, got: {:?}", config)
};
assert_eq!(port, 9050);
assert!(remote_dns);
assert_eq!(auth, Some(("user".to_string(), "pass".to_string())));
}
#[test]
fn parse_unsupported_scheme() {
assert!(ProxyConfig::parse("ftp://proxy.example").is_err());
}
#[test]
fn no_proxy_matching() {
assert!(no_proxy_list_matches("*", "openrouter.ai"));
assert!(no_proxy_list_matches("openrouter.ai", "openrouter.ai"));
assert!(no_proxy_list_matches(".openrouter.ai", "api.openrouter.ai"));
assert!(no_proxy_list_matches(
"example.com, openrouter.ai",
"openrouter.ai"
));
assert!(!no_proxy_list_matches("example.com", "openrouter.ai"));
assert!(!no_proxy_list_matches("router.ai", "openrouter.ai"));
}
}