use std::time::Duration;
use base64::Engine as _;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use crate::net::error::NetError;
use crate::net::tcp::DnsCache;
#[derive(Debug, Clone, PartialEq)]
pub enum ProxyAuth {
None,
UserPass(String, String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProxyConfig {
Http {
host: String,
port: u16,
auth: ProxyAuth,
tls: bool,
},
Socks5 {
host: String,
port: u16,
auth: ProxyAuth,
},
}
impl ProxyConfig {
pub fn resolve(profile_proxy: Option<&str>) -> Result<Option<Self>, NetError> {
if let Ok(env) = std::env::var("BROWSER_OXIDE_PROXY") {
if !env.is_empty() {
return Self::parse(&env).map(Some);
}
}
match profile_proxy {
Some(s) if !s.is_empty() => Self::parse(s).map(Some),
_ => Ok(None),
}
}
pub fn parse(s: &str) -> Result<Self, NetError> {
let url = url::Url::parse(s)
.map_err(|e| NetError::Http(format!("invalid proxy URL {s:?}: {e}")))?;
let host = url
.host_str()
.ok_or_else(|| NetError::Http(format!("proxy URL {s:?} has no host")))?
.to_string();
let port = url
.port()
.or_else(|| match url.scheme() {
"http" | "ws" => Some(80),
"https" | "wss" => Some(443),
"socks5" | "socks5h" => Some(1080),
_ => None,
})
.ok_or_else(|| NetError::Http(format!("proxy URL {s:?} must include a port")))?;
let auth = match (url.username(), url.password()) {
("", _) => ProxyAuth::None,
(u, Some(p)) => ProxyAuth::UserPass(percent_decode(u)?, percent_decode(p)?),
(u, None) => ProxyAuth::UserPass(percent_decode(u)?, String::new()),
};
match url.scheme() {
"http" => Ok(Self::Http {
host,
port,
auth,
tls: false,
}),
"https" => Ok(Self::Http {
host,
port,
auth,
tls: true,
}),
"socks5" | "socks5h" => Ok(Self::Socks5 { host, port, auth }),
other => Err(NetError::Http(format!(
"unsupported proxy scheme {other:?} in {s:?} (use http://, https://, or socks5://)"
))),
}
}
fn host(&self) -> &str {
match self {
Self::Http { host, .. } | Self::Socks5 { host, .. } => host,
}
}
fn port(&self) -> u16 {
match self {
Self::Http { port, .. } | Self::Socks5 { port, .. } => *port,
}
}
}
fn percent_decode(s: &str) -> Result<String, NetError> {
percent_encoding::percent_decode_str(s)
.decode_utf8()
.map(|c| c.into_owned())
.map_err(|e| NetError::Http(format!("invalid percent-encoding in proxy auth: {e}")))
}
pub async fn connect(
target_host: &str,
target_port: u16,
timeout: Duration,
dns_cache: Option<&DnsCache>,
proxy: &ProxyConfig,
) -> Result<TcpStream, NetError> {
let mut stream =
crate::net::tcp::connect_with_cache(proxy.host(), proxy.port(), timeout, dns_cache).await?;
match proxy {
ProxyConfig::Http { auth, tls, .. } => {
if *tls {
eprintln!(
"[proxy] WARN: https:// proxy hop requested for {} but \
TLS-in-TLS isn't implemented; trying plain CONNECT \
(works for most providers)",
proxy.host()
);
}
http_connect(&mut stream, target_host, target_port, auth).await?;
}
ProxyConfig::Socks5 { auth, .. } => {
socks5_handshake(&mut stream, target_host, target_port, auth).await?;
}
}
Ok(stream)
}
async fn http_connect(
stream: &mut TcpStream,
target_host: &str,
target_port: u16,
auth: &ProxyAuth,
) -> Result<(), NetError> {
let mut req = format!(
"CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n"
);
if let ProxyAuth::UserPass(user, pass) = auth {
let token = base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}"));
req.push_str(&format!("Proxy-Authorization: Basic {token}\r\n"));
}
req.push_str("Proxy-Connection: keep-alive\r\n\r\n");
stream
.write_all(req.as_bytes())
.await
.map_err(|e| NetError::Http(format!("proxy CONNECT write failed: {e}")))?;
let mut buf = Vec::with_capacity(512);
let mut tmp = [0u8; 256];
loop {
let n = stream
.read(&mut tmp)
.await
.map_err(|e| NetError::Http(format!("proxy CONNECT read failed: {e}")))?;
if n == 0 {
return Err(NetError::Http(
"proxy CONNECT: server closed before response".into(),
));
}
buf.extend_from_slice(&tmp[..n]);
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
if buf.len() > 8192 {
return Err(NetError::Http("proxy CONNECT: response too large".into()));
}
}
let head = String::from_utf8_lossy(&buf);
let status_line = head.lines().next().unwrap_or_default();
let ok = status_line
.split_whitespace()
.nth(1)
.map(|c| c == "200")
.unwrap_or(false);
if !ok {
return Err(NetError::Http(format!(
"proxy CONNECT denied: {status_line}"
)));
}
Ok(())
}
async fn socks5_handshake(
stream: &mut TcpStream,
target_host: &str,
target_port: u16,
auth: &ProxyAuth,
) -> Result<(), NetError> {
let methods: &[u8] = match auth {
ProxyAuth::None => &[0x00], ProxyAuth::UserPass(_, _) => &[0x02], };
let mut greet = vec![0x05, methods.len() as u8];
greet.extend_from_slice(methods);
stream
.write_all(&greet)
.await
.map_err(|e| NetError::Http(format!("SOCKS5 greet write failed: {e}")))?;
let mut resp = [0u8; 2];
stream
.read_exact(&mut resp)
.await
.map_err(|e| NetError::Http(format!("SOCKS5 greet read failed: {e}")))?;
if resp[0] != 0x05 {
return Err(NetError::Http(format!(
"SOCKS5 unexpected version {}",
resp[0]
)));
}
if resp[1] == 0xFF {
return Err(NetError::Http(
"SOCKS5 server rejected all auth methods".into(),
));
}
if resp[1] == 0x02 {
let (user, pass) = match auth {
ProxyAuth::UserPass(u, p) => (u.as_bytes(), p.as_bytes()),
_ => {
return Err(NetError::Http(
"SOCKS5 server selected USER/PASS but no creds configured".into(),
));
}
};
if user.len() > 255 || pass.len() > 255 {
return Err(NetError::Http(
"SOCKS5 USER/PASS fields must each be ≤255 bytes".into(),
));
}
let mut auth_req = vec![0x01, user.len() as u8];
auth_req.extend_from_slice(user);
auth_req.push(pass.len() as u8);
auth_req.extend_from_slice(pass);
stream
.write_all(&auth_req)
.await
.map_err(|e| NetError::Http(format!("SOCKS5 auth write failed: {e}")))?;
let mut auth_resp = [0u8; 2];
stream
.read_exact(&mut auth_resp)
.await
.map_err(|e| NetError::Http(format!("SOCKS5 auth read failed: {e}")))?;
if auth_resp[1] != 0x00 {
return Err(NetError::Http(format!(
"SOCKS5 auth denied (status={})",
auth_resp[1]
)));
}
}
if target_host.len() > 255 {
return Err(NetError::Http(format!(
"SOCKS5 DOMAINNAME must be ≤255 bytes, got {}",
target_host.len()
)));
}
let mut req = vec![0x05, 0x01, 0x00, 0x03, target_host.len() as u8];
req.extend_from_slice(target_host.as_bytes());
req.extend_from_slice(&target_port.to_be_bytes());
stream
.write_all(&req)
.await
.map_err(|e| NetError::Http(format!("SOCKS5 CONNECT write failed: {e}")))?;
let mut head = [0u8; 4];
stream
.read_exact(&mut head)
.await
.map_err(|e| NetError::Http(format!("SOCKS5 CONNECT read failed: {e}")))?;
if head[0] != 0x05 {
return Err(NetError::Http(format!(
"SOCKS5 unexpected reply version {}",
head[0]
)));
}
if head[1] != 0x00 {
return Err(NetError::Http(format!(
"SOCKS5 CONNECT denied (rep=0x{:02x})",
head[1]
)));
}
let bnd_len = match head[3] {
0x01 => 4, 0x04 => 16, 0x03 => {
let mut len_buf = [0u8; 1];
stream
.read_exact(&mut len_buf)
.await
.map_err(|e| NetError::Http(format!("SOCKS5 BND read failed: {e}")))?;
len_buf[0] as usize
}
other => {
return Err(NetError::Http(format!(
"SOCKS5 unknown ATYP 0x{other:02x} in CONNECT reply"
)));
}
};
let mut bnd = vec![0u8; bnd_len + 2];
stream
.read_exact(&mut bnd)
.await
.map_err(|e| NetError::Http(format!("SOCKS5 BND tail read failed: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_http_no_auth() {
let p = ProxyConfig::parse("http://proxy.example.com:8080").unwrap();
assert_eq!(
p,
ProxyConfig::Http {
host: "proxy.example.com".into(),
port: 8080,
auth: ProxyAuth::None,
tls: false,
}
);
}
#[test]
fn parse_socks5_with_auth() {
let p = ProxyConfig::parse("socks5://alice:s3cret@residential.example.com:1080").unwrap();
assert_eq!(
p,
ProxyConfig::Socks5 {
host: "residential.example.com".into(),
port: 1080,
auth: ProxyAuth::UserPass("alice".into(), "s3cret".into()),
}
);
}
#[test]
fn parse_https_proxy() {
let p = ProxyConfig::parse("https://proxy.example.com:443").unwrap();
match p {
ProxyConfig::Http { tls, .. } => assert!(tls),
_ => panic!("expected Http with tls=true"),
}
}
#[test]
fn parse_rejects_unknown_scheme() {
assert!(ProxyConfig::parse("ftp://proxy:21").is_err());
}
#[test]
fn parse_uses_scheme_default_port() {
let p = ProxyConfig::parse("http://proxy.example.com").unwrap();
match p {
ProxyConfig::Http { port, .. } => assert_eq!(port, 80),
_ => panic!("expected Http"),
}
let p = ProxyConfig::parse("socks5://proxy.example.com").unwrap();
match p {
ProxyConfig::Socks5 { port, .. } => assert_eq!(port, 1080),
_ => panic!("expected Socks5"),
}
}
#[test]
fn parse_percent_decoded_password() {
let p = ProxyConfig::parse("http://u:a%3Ab@proxy:8080").unwrap();
let auth = match &p {
ProxyConfig::Http { auth, .. } => auth.clone(),
_ => panic!(),
};
assert_eq!(auth, ProxyAuth::UserPass("u".into(), "a:b".into()));
}
#[test]
fn resolve_env_overrides_profile() {
let saved = std::env::var("BROWSER_OXIDE_PROXY").ok();
std::env::set_var("BROWSER_OXIDE_PROXY", "socks5://env.example:1080");
let r = ProxyConfig::resolve(Some("http://profile.example:8080")).unwrap();
match r {
Some(ProxyConfig::Socks5 { host, .. }) => assert_eq!(host, "env.example"),
other => panic!("expected env override Socks5, got {other:?}"),
}
match saved {
Some(v) => std::env::set_var("BROWSER_OXIDE_PROXY", v),
None => std::env::remove_var("BROWSER_OXIDE_PROXY"),
}
}
#[tokio::test]
async fn socks5_handshake_roundtrip() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut greet = [0u8; 3];
sock.read_exact(&mut greet).await.unwrap();
assert_eq!(greet, [0x05, 0x01, 0x00]);
sock.write_all(&[0x05, 0x00]).await.unwrap();
let mut head = [0u8; 5];
sock.read_exact(&mut head).await.unwrap();
assert_eq!(head[0], 0x05);
assert_eq!(head[1], 0x01); assert_eq!(head[3], 0x03); let host_len = head[4] as usize;
let mut host_buf = vec![0u8; host_len + 2];
sock.read_exact(&mut host_buf).await.unwrap();
let host_str = std::str::from_utf8(&host_buf[..host_len])
.unwrap()
.to_string();
assert_eq!(host_str, "target.example.com");
sock.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await
.unwrap();
});
let proxy = ProxyConfig::Socks5 {
host: "127.0.0.1".into(),
port: proxy_port,
auth: ProxyAuth::None,
};
let _stream = connect(
"target.example.com",
443,
std::time::Duration::from_secs(2),
None,
&proxy,
)
.await
.expect("SOCKS5 round-trip should succeed");
server.await.unwrap();
}
#[test]
fn resolve_profile_when_no_env() {
let saved = std::env::var("BROWSER_OXIDE_PROXY").ok();
std::env::remove_var("BROWSER_OXIDE_PROXY");
let r = ProxyConfig::resolve(Some("http://profile.example:8080")).unwrap();
assert!(matches!(r, Some(ProxyConfig::Http { .. })));
let r = ProxyConfig::resolve(None).unwrap();
assert!(r.is_none());
let r = ProxyConfig::resolve(Some("")).unwrap();
assert!(r.is_none());
if let Some(v) = saved {
std::env::set_var("BROWSER_OXIDE_PROXY", v);
}
}
}