use std::{env, net::TcpStream};
use anyhow::{Context, Result, bail};
use io_proxy::{
client::{connect_http, connect_socks5},
http::connect::HttpCredentials,
socks::v5::{address::Socks5Address, auth::Socks5Credentials},
};
use log::debug;
use secrecy::{ExposeSecret, SecretString};
use url::Url;
#[derive(Clone, Debug)]
pub struct ProxyAuth {
pub user: String,
pub pass: SecretString,
}
#[derive(Clone, Debug, Default)]
pub enum Proxy {
None,
Socks5 {
host: String,
port: u16,
auth: Option<ProxyAuth>,
},
Http {
host: String,
port: u16,
auth: Option<ProxyAuth>,
},
#[default]
System,
}
impl Proxy {
pub fn from_url(raw: &str) -> Result<Proxy> {
let url = Url::parse(raw).with_context(|| format!("parse proxy url {raw}"))?;
let host = url
.host_str()
.with_context(|| format!("proxy url {raw} has no host"))?
.to_string();
let auth = match url.username() {
"" => None,
user => Some(ProxyAuth {
user: user.to_string(),
pass: SecretString::from(url.password().unwrap_or_default().to_string()),
}),
};
match url.scheme() {
"socks5" | "socks5h" | "socks" => {
let port = url.port().unwrap_or(1080);
Ok(Proxy::Socks5 { host, port, auth })
}
"http" | "https" => {
let port = url.port().unwrap_or(8080);
Ok(Proxy::Http { host, port, auth })
}
other => bail!("unsupported proxy scheme `{other}` in {raw}"),
}
}
}
pub fn dial(host: &str, port: u16, proxy: &Proxy) -> Result<TcpStream> {
let (proxy, source) = match proxy {
Proxy::System => resolve_from_env(host),
other => (other.clone(), "config"),
};
match &proxy {
Proxy::None => {
debug!("dial {host}:{port} directly (source: {source})");
Ok(TcpStream::connect((host, port))
.with_context(|| format!("connect {host}:{port}"))?)
}
Proxy::Socks5 {
host: phost,
port: pport,
auth,
} => {
debug!("dial {host}:{port} via socks5 proxy {phost}:{pport} (source: {source})");
let mut tcp = TcpStream::connect((phost.as_str(), *pport))
.with_context(|| format!("connect socks5 proxy {phost}:{pport}"))?;
let target = Socks5Address::new(host, port)?;
let credentials = match auth {
Some(auth) => Some(Socks5Credentials::new(
&auth.user,
auth.pass.expose_secret(),
)?),
None => None,
};
connect_socks5(&mut tcp, target, credentials)
.with_context(|| format!("socks5 handshake to {host}:{port}"))?;
Ok(tcp)
}
Proxy::Http {
host: phost,
port: pport,
auth,
} => {
debug!("dial {host}:{port} via http proxy {phost}:{pport} (source: {source})");
let mut tcp = TcpStream::connect((phost.as_str(), *pport))
.with_context(|| format!("connect http proxy {phost}:{pport}"))?;
let credentials = auth
.as_ref()
.map(|auth| HttpCredentials::new(&auth.user, auth.pass.expose_secret()));
connect_http(&mut tcp, host, port, credentials)
.with_context(|| format!("http connect to {host}:{port}"))?;
Ok(tcp)
}
Proxy::System => unreachable!("System proxy resolved before match"),
}
}
fn env_var(lower: &str) -> Option<String> {
let non_empty = |v: String| if v.is_empty() { None } else { Some(v) };
env::var(lower)
.ok()
.and_then(non_empty)
.or_else(|| env::var(lower.to_uppercase()).ok().and_then(non_empty))
}
fn is_bypassed(host: &str) -> bool {
let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
if host == "localhost" || host.ends_with(".localhost") || host == "127.0.0.1" || host == "::1" {
return true;
}
let Some(no_proxy) = env_var("no_proxy") else {
return false;
};
for entry in no_proxy.split(',') {
let entry = entry
.trim()
.trim_start_matches('.')
.trim_end_matches('.')
.to_ascii_lowercase();
if entry.is_empty() {
continue;
}
if entry == "*" {
return true;
}
if host == entry || host.ends_with(&format!(".{entry}")) {
return true;
}
}
false
}
fn resolve_from_env(target_host: &str) -> (Proxy, &'static str) {
if is_bypassed(target_host) {
return (Proxy::None, "no_proxy");
}
for (var, label) in [("all_proxy", "all_proxy"), ("https_proxy", "https_proxy")] {
let Some(raw) = env_var(var) else {
continue;
};
match Proxy::from_url(&raw) {
Ok(proxy) => return (proxy, label),
Err(err) => debug!("ignoring invalid {label}: {err:#}"),
}
}
(Proxy::None, "direct")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_url_parses_socks5_with_auth() {
let proxy = Proxy::from_url("socks5://alice:secret@10.0.0.1:1080").unwrap();
match proxy {
Proxy::Socks5 { host, port, auth } => {
assert_eq!(host, "10.0.0.1");
assert_eq!(port, 1080);
let auth = auth.unwrap();
assert_eq!(auth.user, "alice");
assert_eq!(auth.pass.expose_secret(), "secret");
}
other => panic!("expected socks5, got {other:?}"),
}
}
#[test]
fn from_url_defaults_http_port_and_no_auth() {
let proxy = Proxy::from_url("http://proxy.corp").unwrap();
match proxy {
Proxy::Http { host, port, auth } => {
assert_eq!(host, "proxy.corp");
assert_eq!(port, 8080);
assert!(auth.is_none());
}
other => panic!("expected http, got {other:?}"),
}
}
#[test]
fn from_url_rejects_unknown_scheme() {
assert!(Proxy::from_url("ftp://proxy.corp:21").is_err());
}
#[test]
fn loopback_is_always_bypassed() {
assert!(is_bypassed("localhost"));
assert!(is_bypassed("127.0.0.1"));
assert!(is_bypassed("::1"));
assert!(is_bypassed("dev.localhost"));
}
}