use std::net::Ipv6Addr;
use ipnet::IpNet;
use percent_encoding::percent_decode_str;
use proxyvars::NoProxy;
use url::Url;
pub(super) struct OutboundConfig {
pub proxy: Option<ProxyConfig>,
pub no_proxy: Option<NoProxy>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct ProxyConfig {
pub kind: ProxyKind,
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ProxyKind {
Http,
Socks5 { remote_dns: bool },
}
impl OutboundConfig {
pub fn from_sources(
outbound_proxy: Option<&str>,
no_proxy: Option<&str>,
use_env: bool,
) -> Result<Self, String> {
Self::from_sources_with_env(outbound_proxy, no_proxy, use_env, env_value)
}
pub(super) fn from_sources_with_env(
outbound_proxy: Option<&str>,
no_proxy: Option<&str>,
use_env: bool,
env_get: impl Fn(&str) -> Option<String>,
) -> Result<Self, String> {
let proxy = select_proxy(outbound_proxy, use_env, &env_get)?;
let no_proxy = if proxy.is_some() {
let no_proxy_source = select_no_proxy(no_proxy, use_env, &env_get);
if let Some(no_proxy) = no_proxy_source.as_deref()
&& !no_proxy.is_empty()
{
validate_no_proxy(no_proxy)?;
}
no_proxy_source
.filter(|no_proxy| !no_proxy.is_empty())
.map(NoProxy::from)
} else {
None
};
Ok(Self { proxy, no_proxy })
}
}
impl ProxyConfig {
fn parse(raw: &str) -> Result<Self, String> {
let url = Url::parse(raw).map_err(|_| "invalid outbound proxy URL".to_string())?;
validate_proxy_url_shape(&url)?;
let kind = match url.scheme().to_ascii_lowercase().as_str() {
"http" => ProxyKind::Http,
"socks5" => ProxyKind::Socks5 { remote_dns: false },
"socks5h" => ProxyKind::Socks5 { remote_dns: true },
"https" => {
return Err(
"HTTPS outbound proxy URLs are not supported; use an http:// CONNECT proxy"
.to_string(),
);
}
scheme => return Err(format!("unsupported outbound proxy scheme: {scheme}")),
};
let host = url
.host_str()
.ok_or_else(|| "outbound proxy URL must include a host".to_string())?
.to_string();
let port = url.port().unwrap_or_else(|| default_port(kind));
let username = decode_userinfo(url.username())?;
let password = url.password().map(decode_required_userinfo).transpose()?;
if username.is_none() && password.is_some() {
return Err("proxy URL password requires a username".to_string());
}
Ok(Self {
kind,
host,
port,
username,
password,
})
}
pub fn summary(&self) -> String {
let scheme = match self.kind {
ProxyKind::Http => "http",
ProxyKind::Socks5 { remote_dns: false } => "socks5",
ProxyKind::Socks5 { remote_dns: true } => "socks5h",
};
let authority = authority(&self.host, self.port);
if self.username.is_some() {
format!("{scheme}://user:***@{authority}")
} else {
format!("{scheme}://{authority}")
}
}
}
pub(super) fn authority(host: &str, port: u16) -> String {
if host.contains(':') && !host.starts_with('[') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
}
}
pub(super) fn http_host(host: &str) -> String {
if host.contains(':') && !host.starts_with('[') {
format!("[{host}]")
} else {
host.to_string()
}
}
pub(super) fn target_url(host: &str, port: u16) -> String {
format!("https://{}", authority(host, port))
}
fn default_port(kind: ProxyKind) -> u16 {
match kind {
ProxyKind::Http => 80,
ProxyKind::Socks5 { .. } => 1080,
}
}
fn decode_userinfo(value: &str) -> Result<Option<String>, String> {
if value.is_empty() {
Ok(None)
} else {
decode_required_userinfo(value).map(Some)
}
}
fn decode_required_userinfo(value: &str) -> Result<String, String> {
percent_decode_str(value)
.decode_utf8()
.map(|value| value.into_owned())
.map_err(|_| "proxy URL userinfo is not valid UTF-8".to_string())
}
fn select_proxy(
explicit: Option<&str>,
use_env: bool,
env_get: &impl Fn(&str) -> Option<String>,
) -> Result<Option<ProxyConfig>, String> {
if let Some(value) = explicit {
let value = value.trim();
if !value.is_empty() {
if is_direct_marker(value) {
return Ok(None);
}
return ProxyConfig::parse(value).map(Some);
}
}
if !use_env {
return Ok(None);
}
select_env_proxy([
("HTTPS_PROXY", env_get("HTTPS_PROXY")),
("https_proxy", env_get("https_proxy")),
("ALL_PROXY", env_get("ALL_PROXY")),
("all_proxy", env_get("all_proxy")),
("HTTP_PROXY", env_get("HTTP_PROXY")),
("http_proxy", env_get("http_proxy")),
])
}
fn select_env_proxy(
values: impl IntoIterator<Item = (&'static str, Option<String>)>,
) -> Result<Option<ProxyConfig>, String> {
let mut first_error = None;
for (name, value) in values {
let Some(value) = value else {
continue;
};
let value = value.trim();
if value.is_empty() {
continue;
}
if is_direct_marker(value) {
return Ok(None);
}
match ProxyConfig::parse(value) {
Ok(proxy) => return Ok(Some(proxy)),
Err(err) => {
first_error.get_or_insert_with(|| format!("{name}: {err}"));
}
}
}
match first_error {
Some(err) => Err(format!(
"no supported outbound proxy URL found in environment ({err})"
)),
None => Ok(None),
}
}
fn select_no_proxy(
explicit: Option<&str>,
use_env: bool,
env_get: &impl Fn(&str) -> Option<String>,
) -> Option<String> {
if let Some(value) = explicit {
return Some(value.trim().to_string());
}
if !use_env {
return None;
}
first_non_empty([env_get("NO_PROXY"), env_get("no_proxy")])
}
fn first_non_empty(values: impl IntoIterator<Item = Option<String>>) -> Option<String> {
values
.into_iter()
.flatten()
.map(|value| value.trim().to_string())
.find(|value| !value.is_empty())
}
fn env_value(name: &str) -> Option<String> {
std::env::var(name).ok()
}
fn is_direct_marker(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"direct" | "none" | "off"
)
}
fn validate_proxy_url_shape(url: &Url) -> Result<(), String> {
if !matches!(url.path(), "" | "/") || url.query().is_some() || url.fragment().is_some() {
return Err(
"outbound proxy URL must not include a path, query string or fragment".to_string(),
);
}
Ok(())
}
fn validate_no_proxy(raw: &str) -> Result<(), String> {
for entry in raw
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
{
validate_no_proxy_entry(entry)?;
}
Ok(())
}
fn validate_no_proxy_entry(entry: &str) -> Result<(), String> {
if entry == "*" {
return Ok(());
}
if entry.contains("://") {
return Err("NO_PROXY entries must not include a URL scheme".to_string());
}
if entry.parse::<IpNet>().is_ok() {
return Ok(());
}
if entry.starts_with('[') {
return validate_bracketed_ipv6_no_proxy(entry);
}
if entry.contains('/') {
return Err("invalid NO_PROXY CIDR entry".to_string());
}
if entry.contains(':') {
let Some((host, port)) = entry.rsplit_once(':') else {
return Err("invalid NO_PROXY entry".to_string());
};
if host.contains(':') {
return Err("IPv6 NO_PROXY entries with ports must use brackets".to_string());
}
validate_hostname_no_proxy(host)?;
validate_no_proxy_port(port)?;
return Ok(());
}
validate_hostname_no_proxy(entry)
}
fn validate_bracketed_ipv6_no_proxy(entry: &str) -> Result<(), String> {
let Some(end) = entry.find(']') else {
return Err("invalid bracketed IPv6 NO_PROXY entry".to_string());
};
entry[1..end]
.parse::<Ipv6Addr>()
.map_err(|_| "invalid bracketed IPv6 NO_PROXY entry".to_string())?;
let rest = &entry[end + 1..];
if rest.is_empty() {
return Ok(());
}
let Some(port) = rest.strip_prefix(':') else {
return Err("invalid bracketed IPv6 NO_PROXY entry".to_string());
};
validate_no_proxy_port(port)
}
fn validate_no_proxy_port(port: &str) -> Result<(), String> {
if port.is_empty() || port.parse::<u16>().is_err() {
return Err("invalid NO_PROXY entry port".to_string());
}
Ok(())
}
fn validate_hostname_no_proxy(host: &str) -> Result<(), String> {
let host = host
.strip_prefix("*.")
.or_else(|| host.strip_prefix('.'))
.unwrap_or(host);
if host.is_empty()
|| host.contains(['/', '[', ']', ':'])
|| host.split('.').any(|label| {
label.is_empty()
|| label.starts_with('-')
|| label.ends_with('-')
|| !label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
})
{
return Err("invalid NO_PROXY host entry".to_string());
}
Ok(())
}