diff --git a/bitreq/README.md b/bitreq/README.md index c13b2fd3..4e2b447e 100644 --- a/bitreq/README.md +++ b/bitreq/README.md @@ -7,10 +7,29 @@ This crate is a fork for the very nice rename it because I wanted to totally gut it and provide a crate with different goals. Many thanks to the original author. -Simple, minimal-dependency HTTP client. Optional features for http -proxies (`proxy`), async support (`async`, `async-https`), and https -with various TLS implementations (`https-rustls`, `https-rustls-probe`, -and `https` which is an alias for `https-rustls`). +Simple, minimal-dependency HTTP client. Optional features for HTTP +proxies and SOCKS5 proxies (`proxy`), async support (`async`, +`async-https`), and https with various TLS implementations +(`https-rustls`, `https-rustls-probe`, and `https` which is an alias +for `https-rustls`). + +### Proxy Support + +The `proxy` feature enables both HTTP CONNECT and SOCKS5 proxies: + +```rust +// HTTP CONNECT proxy +let proxy = bitreq::Proxy::new_http("http://proxy.example.com:8080").unwrap(); +let response = bitreq::get("http://example.com").with_proxy(proxy).send(); + +// SOCKS5 proxy (e.g., Tor) +let proxy = bitreq::Proxy::new_socks5("127.0.0.1:9050").unwrap(); +let response = bitreq::get("http://example.com").with_proxy(proxy).send(); +``` + +SOCKS5 proxies use domain-based addressing (RFC 1928 ATYP 0x03), so +DNS resolution happens at the proxy. This is required for `.onion` +routing through Tor. Without any optional features, my casual testing indicates about 100 KB additional executable size for stripped release builds using this diff --git a/bitreq/src/connection.rs b/bitreq/src/connection.rs index f8b98c13..ebcedcf2 100644 --- a/bitreq/src/connection.rs +++ b/bitreq/src/connection.rs @@ -336,8 +336,14 @@ impl AsyncConnection { async fn connect(params: ConnectionParams<'_>) -> Result { #[cfg(feature = "proxy")] match ¶ms.proxy { + Some(proxy) if proxy.is_socks5() => { + // SOCKS5 proxy + let mut tcp = Self::tcp_connect(&proxy.server, proxy.port).await?; + proxy.socks5_handshake_async(&mut tcp, params.host, params.port).await?; + Ok(tcp) + } Some(proxy) => { - // do proxy things + // HTTP CONNECT proxy let mut tcp = Self::tcp_connect(&proxy.server, proxy.port).await?; let proxy_request = proxy.connect(params.host, params.port); @@ -709,8 +715,14 @@ impl Connection { ) -> Result { #[cfg(feature = "proxy")] match ¶ms.proxy { + Some(proxy) if proxy.is_socks5() => { + // SOCKS5 proxy + let mut tcp = Self::tcp_connect(&proxy.server, proxy.port, timeout_at)?; + proxy.socks5_handshake_sync(&mut tcp, params.host, params.port)?; + Ok(tcp) + } Some(proxy) => { - // do proxy things + // HTTP CONNECT proxy let mut tcp = Self::tcp_connect(&proxy.server, proxy.port, timeout_at)?; write!(tcp, "{}", proxy.connect(params.host, params.port))?; diff --git a/bitreq/src/proxy.rs b/bitreq/src/proxy.rs index 37d9673c..0dbe5d36 100644 --- a/bitreq/src/proxy.rs +++ b/bitreq/src/proxy.rs @@ -3,17 +3,22 @@ use base64::engine::Engine; use crate::error::Error; -/// Kind of proxy connection (Basic, Digest, etc) +/// Kind of proxy connection (Basic, Digest, SOCKS5, etc) #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub(crate) enum ProxyKind { Basic, + Socks5, } -/// Proxy configuration. Only HTTP CONNECT proxies are supported (no SOCKS or -/// HTTPS). +/// Proxy configuration. Supports HTTP CONNECT proxies ([`Proxy::new_http`]) +/// and SOCKS5 proxies ([`Proxy::new_socks5`]). /// -/// When credentials are provided, the Basic authentication type is used for -/// Proxy-Authorization. +/// SOCKS5 uses domain-based addressing (RFC 1928 ATYP 0x03), so DNS +/// resolution is performed by the proxy. This enables routing through +/// Tor, including `.onion` addresses. +/// +/// For HTTP CONNECT proxies, when credentials are provided, the Basic +/// authentication type is used for Proxy-Authorization. #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct Proxy { pub(crate) server: String, @@ -86,6 +91,131 @@ impl Proxy { }) } + /// Creates a new Proxy configuration for a SOCKS5 proxy. + /// + /// Supported proxy format is: + /// + /// ```plaintext + /// [socks5://]host[:port] + /// ``` + /// + /// The default port is 1080. + /// + /// # Example + /// + /// ``` + /// let proxy = bitreq::Proxy::new_socks5("127.0.0.1:9050").unwrap(); + /// let request = bitreq::post("http://example.com").with_proxy(proxy); + /// ``` + /// + pub fn new_socks5>(proxy: S) -> Result { + let proxy = proxy.as_ref(); + let authority = if let Some((proto, auth)) = split_once(proxy, "://") { + if proto != "socks5" { + return Err(Error::BadProxy); + } + auth + } else { + proxy + }; + + let (host, port) = Proxy::parse_address(authority)?; + + Ok(Self { + server: host, + user: None, + password: None, + port: port.unwrap_or(1080), + kind: ProxyKind::Socks5, + }) + } + + /// Creates a new SOCKS5 proxy with username/password credentials. + /// + /// Credentials trigger RFC 1929 username/password authentication during + /// the SOCKS5 handshake. Tor uses credentials for circuit isolation: + /// connections with different credentials are routed through separate + /// circuits, preventing correlation. + /// + /// # Example + /// + /// ``` + /// let proxy = bitreq::Proxy::new_socks5_with_credentials( + /// "127.0.0.1:9050", "session-1", "x" + /// ).unwrap(); + /// ``` + /// + pub fn new_socks5_with_credentials>( + proxy: S, + user: &str, + password: &str, + ) -> Result { + // RFC 1929: username and password are each 1-255 bytes + if user.is_empty() || user.len() > 255 || password.len() > 255 { + return Err(Error::BadProxy); + } + let mut p = Self::new_socks5(proxy)?; + p.user = Some(user.to_string()); + p.password = Some(password.to_string()); + Ok(p) + } + + /// Returns true if this is a SOCKS5 proxy. + pub(crate) fn is_socks5(&self) -> bool { + matches!(self.kind, ProxyKind::Socks5) + } + + /// Build the SOCKS5 greeting bytes. + /// Returns (greeting_bytes, expected_auth_method). + fn socks5_greeting(&self) -> ([u8; 3], u8) { + let method = if self.user.is_some() { 0x02 } else { 0x00 }; + ([0x05, 0x01, method], method) + } + + /// Validate the SOCKS5 greeting response. + fn socks5_check_greeting(resp: &[u8; 2], expected_method: u8) -> Result<(), Error> { + if resp[0] != 0x05 || resp[1] != expected_method { + return Err(Error::ProxyConnect); + } + Ok(()) + } + + /// Build the RFC 1929 username/password auth request. + /// Returns None if no credentials are set. + fn socks5_auth_request(&self) -> Option> { + let user = self.user.as_ref()?; + let pass = self.password.as_deref().unwrap_or(""); + let mut req = Vec::with_capacity(3 + user.len() + pass.len()); + req.push(0x01); // sub-negotiation version + req.push(user.len() as u8); + req.extend_from_slice(user.as_bytes()); + req.push(pass.len() as u8); + req.extend_from_slice(pass.as_bytes()); + Some(req) + } + + /// Validate the RFC 1929 auth response. + fn socks5_check_auth(resp: &[u8; 2]) -> Result<(), Error> { + if resp[1] != 0x00 { + return Err(Error::InvalidProxyCreds); + } + Ok(()) + } + + /// Build the SOCKS5 CONNECT request for a domain target. + fn socks5_connect_request(target_host: &str, target_port: u16) -> Result, Error> { + let host_bytes = target_host.as_bytes(); + if host_bytes.len() > 255 { + return Err(Error::ProxyConnect); + } + let mut req = Vec::with_capacity(7 + host_bytes.len()); + req.extend_from_slice(&[0x05, 0x01, 0x00, 0x03, host_bytes.len() as u8]); + req.extend_from_slice(host_bytes); + req.push((target_port >> 8) as u8); + req.push((target_port & 0xff) as u8); + Ok(req) + } + pub(crate) fn connect(&self, host: &str, port: u16) -> String { let authorization = if let Some(user) = &self.user { match self.kind { @@ -97,6 +227,7 @@ impl Proxy { }; format!("Proxy-Authorization: Basic {}\r\n", creds) } + ProxyKind::Socks5 => unreachable!("SOCKS5 uses binary handshake, not HTTP CONNECT"), } } else { String::new() @@ -115,6 +246,131 @@ impl Proxy { _ => Err(Error::BadProxy), } } + + /// Perform a SOCKS5 handshake on a connected TCP stream (sync). + #[cfg(feature = "std")] + pub(crate) fn socks5_handshake_sync( + &self, + stream: &mut std::net::TcpStream, + target_host: &str, + target_port: u16, + ) -> Result<(), Error> { + use std::io::{Read, Write}; + + // 1. Greeting + let (greeting, expected_method) = self.socks5_greeting(); + stream.write_all(&greeting).map_err(Error::IoError)?; + stream.flush().map_err(Error::IoError)?; + + let mut greeting_resp = [0u8; 2]; + stream.read_exact(&mut greeting_resp).map_err(Error::IoError)?; + Self::socks5_check_greeting(&greeting_resp, expected_method)?; + + // 2. Username/password auth (RFC 1929), if credentials set + if let Some(auth_req) = self.socks5_auth_request() { + stream.write_all(&auth_req).map_err(Error::IoError)?; + stream.flush().map_err(Error::IoError)?; + + let mut auth_resp = [0u8; 2]; + stream.read_exact(&mut auth_resp).map_err(Error::IoError)?; + Self::socks5_check_auth(&auth_resp)?; + } + + // 3. Connect request + let req = Self::socks5_connect_request(target_host, target_port)?; + stream.write_all(&req).map_err(Error::IoError)?; + stream.flush().map_err(Error::IoError)?; + + // 4. Read response header + let mut connect_resp = [0u8; 4]; + stream.read_exact(&mut connect_resp).map_err(Error::IoError)?; + if connect_resp[0] != 0x05 || connect_resp[1] != 0x00 { + return Err(Error::ProxyConnect); + } + + // Drain the bound address + match connect_resp[3] { + 0x01 => { // IPv4: 4 bytes + 2 port + let mut buf = [0u8; 6]; + stream.read_exact(&mut buf).map_err(Error::IoError)?; + } + 0x03 => { // Domain: 1 len byte + domain + 2 port + let mut len = [0u8; 1]; + stream.read_exact(&mut len).map_err(Error::IoError)?; + let mut buf = vec![0u8; len[0] as usize + 2]; + stream.read_exact(&mut buf).map_err(Error::IoError)?; + } + 0x04 => { // IPv6: 16 bytes + 2 port + let mut buf = [0u8; 18]; + stream.read_exact(&mut buf).map_err(Error::IoError)?; + } + _ => return Err(Error::ProxyConnect), + } + + Ok(()) + } + + /// Perform a SOCKS5 handshake on a connected async TCP stream. + #[cfg(feature = "async")] + pub(crate) async fn socks5_handshake_async( + &self, + stream: &mut tokio::net::TcpStream, + target_host: &str, + target_port: u16, + ) -> Result<(), Error> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // 1. Greeting + let (greeting, expected_method) = self.socks5_greeting(); + stream.write_all(&greeting).await.map_err(Error::IoError)?; + stream.flush().await.map_err(Error::IoError)?; + + let mut greeting_resp = [0u8; 2]; + stream.read_exact(&mut greeting_resp).await.map_err(Error::IoError)?; + Self::socks5_check_greeting(&greeting_resp, expected_method)?; + + // 2. Username/password auth (RFC 1929), if credentials set + if let Some(auth_req) = self.socks5_auth_request() { + stream.write_all(&auth_req).await.map_err(Error::IoError)?; + stream.flush().await.map_err(Error::IoError)?; + + let mut auth_resp = [0u8; 2]; + stream.read_exact(&mut auth_resp).await.map_err(Error::IoError)?; + Self::socks5_check_auth(&auth_resp)?; + } + + // 3. Connect request + let req = Self::socks5_connect_request(target_host, target_port)?; + stream.write_all(&req).await.map_err(Error::IoError)?; + stream.flush().await.map_err(Error::IoError)?; + + // 4. Read response header + let mut connect_resp = [0u8; 4]; + stream.read_exact(&mut connect_resp).await.map_err(Error::IoError)?; + if connect_resp[0] != 0x05 || connect_resp[1] != 0x00 { + return Err(Error::ProxyConnect); + } + + match connect_resp[3] { + 0x01 => { + let mut buf = [0u8; 6]; + stream.read_exact(&mut buf).await.map_err(Error::IoError)?; + } + 0x03 => { + let mut len = [0u8; 1]; + stream.read_exact(&mut len).await.map_err(Error::IoError)?; + let mut buf = vec![0u8; len[0] as usize + 2]; + stream.read_exact(&mut buf).await.map_err(Error::IoError)?; + } + 0x04 => { + let mut buf = [0u8; 18]; + stream.read_exact(&mut buf).await.map_err(Error::IoError)?; + } + _ => return Err(Error::ProxyConnect), + } + + Ok(()) + } } #[allow(clippy::manual_split_once)] @@ -156,4 +412,357 @@ mod tests { assert_eq!(proxy.server, String::from("localhost")); assert_eq!(proxy.port, 1080); } + + // --- SOCKS5 parsing tests --- + + #[test] + fn parse_socks5_host_port() { + let proxy = Proxy::new_socks5("127.0.0.1:9050").unwrap(); + assert_eq!(proxy.server, "127.0.0.1"); + assert_eq!(proxy.port, 9050); + assert!(proxy.is_socks5()); + assert_eq!(proxy.user, None); + } + + #[test] + fn parse_socks5_with_protocol() { + let proxy = Proxy::new_socks5("socks5://localhost:1080").unwrap(); + assert_eq!(proxy.server, "localhost"); + assert_eq!(proxy.port, 1080); + assert!(proxy.is_socks5()); + } + + #[test] + fn parse_socks5_default_port() { + let proxy = Proxy::new_socks5("localhost").unwrap(); + assert_eq!(proxy.server, "localhost"); + assert_eq!(proxy.port, 1080); // default SOCKS5 port + } + + #[test] + fn parse_socks5_wrong_protocol() { + assert!(Proxy::new_socks5("http://localhost:1080").is_err()); + } + + #[test] + fn socks5_is_socks5() { + let http = Proxy::new_http("localhost:8080").unwrap(); + let socks = Proxy::new_socks5("localhost:1080").unwrap(); + assert!(!http.is_socks5()); + assert!(socks.is_socks5()); + } + + #[test] + fn parse_socks5_with_credentials() { + let proxy = Proxy::new_socks5_with_credentials("127.0.0.1:9050", "user1", "pass1").unwrap(); + assert_eq!(proxy.server, "127.0.0.1"); + assert_eq!(proxy.port, 9050); + assert!(proxy.is_socks5()); + assert_eq!(proxy.user, Some("user1".to_string())); + assert_eq!(proxy.password, Some("pass1".to_string())); + } + + #[test] + fn socks5_credentials_length_validation() { + // Empty username rejected + assert!(Proxy::new_socks5_with_credentials("localhost:9050", "", "pass").is_err()); + // Username >255 bytes rejected + let long_user = "a".repeat(256); + assert!(Proxy::new_socks5_with_credentials("localhost:9050", &long_user, "pass").is_err()); + // Password >255 bytes rejected + let long_pass = "a".repeat(256); + assert!(Proxy::new_socks5_with_credentials("localhost:9050", "user", &long_pass).is_err()); + // Max length (255) accepted + let max_user = "a".repeat(255); + assert!(Proxy::new_socks5_with_credentials("localhost:9050", &max_user, "x").is_ok()); + } + + // --- SOCKS5 handshake tests (sync, with mock server) --- + + #[cfg(feature = "std")] + mod socks5_handshake { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + + /// Mock SOCKS5 server that accepts one connection and performs the handshake. + /// Returns the target host and port that the client requested. + fn mock_socks5_server( + listener: &TcpListener, + reply_status: u8, + ) -> (String, u16) { + let (mut stream, _) = listener.accept().unwrap(); + + // 1. Read greeting + let mut greeting = [0u8; 3]; + stream.read_exact(&mut greeting).unwrap(); + assert_eq!(greeting[0], 0x05, "SOCKS version must be 5"); + assert_eq!(greeting[1], 0x01, "1 auth method"); + assert_eq!(greeting[2], 0x00, "no-auth method"); + + // 2. Reply: accept no-auth + stream.write_all(&[0x05, 0x00]).unwrap(); + stream.flush().unwrap(); + + // 3. Read connect request header + let mut header = [0u8; 4]; + stream.read_exact(&mut header).unwrap(); + assert_eq!(header[0], 0x05, "SOCKS version"); + assert_eq!(header[1], 0x01, "CONNECT command"); + assert_eq!(header[2], 0x00, "reserved"); + assert_eq!(header[3], 0x03, "domain address type"); + + // Read domain + let mut len = [0u8; 1]; + stream.read_exact(&mut len).unwrap(); + let mut domain = vec![0u8; len[0] as usize]; + stream.read_exact(&mut domain).unwrap(); + let host = String::from_utf8(domain).unwrap(); + + // Read port + let mut port_bytes = [0u8; 2]; + stream.read_exact(&mut port_bytes).unwrap(); + let port = ((port_bytes[0] as u16) << 8) | port_bytes[1] as u16; + + // 4. Send reply (IPv4 bound address 0.0.0.0:0) + stream.write_all(&[ + 0x05, reply_status, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, // IPv4 0.0.0.0 + 0x00, 0x00, // port 0 + ]).unwrap(); + stream.flush().unwrap(); + + (host, port) + } + + #[test] + fn handshake_success() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let proxy = Proxy::new_socks5(format!("127.0.0.1:{}", addr.port())).unwrap(); + + let server = std::thread::spawn(move || { + mock_socks5_server(&listener, 0x00) + }); + + let mut stream = std::net::TcpStream::connect(addr).unwrap(); + let result = proxy.socks5_handshake_sync(&mut stream, "example.com", 443); + assert!(result.is_ok()); + + let (host, port) = server.join().unwrap(); + assert_eq!(host, "example.com"); + assert_eq!(port, 443); + } + + #[test] + fn handshake_onion_domain() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let proxy = Proxy::new_socks5(format!("127.0.0.1:{}", addr.port())).unwrap(); + + let server = std::thread::spawn(move || { + mock_socks5_server(&listener, 0x00) + }); + + let mut stream = std::net::TcpStream::connect(addr).unwrap(); + let onion = "mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion"; + let result = proxy.socks5_handshake_sync(&mut stream, onion, 9735); + assert!(result.is_ok()); + + let (host, port) = server.join().unwrap(); + assert_eq!(host, onion); + assert_eq!(port, 9735); + } + + #[test] + fn handshake_server_rejects() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let proxy = Proxy::new_socks5(format!("127.0.0.1:{}", addr.port())).unwrap(); + + let server = std::thread::spawn(move || { + mock_socks5_server(&listener, 0x05) // connection refused + }); + + let mut stream = std::net::TcpStream::connect(addr).unwrap(); + let result = proxy.socks5_handshake_sync(&mut stream, "blocked.com", 80); + assert!(result.is_err()); + + server.join().unwrap(); + } + + #[test] + fn handshake_port_encoding() { + // Test that port bytes are correctly big-endian + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let proxy = Proxy::new_socks5(format!("127.0.0.1:{}", addr.port())).unwrap(); + + let server = std::thread::spawn(move || { + mock_socks5_server(&listener, 0x00) + }); + + let mut stream = std::net::TcpStream::connect(addr).unwrap(); + // Port 9735 = 0x2607 (tests both bytes matter) + proxy.socks5_handshake_sync(&mut stream, "test.com", 9735).unwrap(); + + let (_, port) = server.join().unwrap(); + assert_eq!(port, 9735); + } + + #[test] + fn handshake_domain_too_long() { + // Domain >255 bytes should fail during the connect request phase. + // We need a mock server to handle the initial greeting handshake. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let proxy = Proxy::new_socks5(format!("127.0.0.1:{}", addr.port())).unwrap(); + + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + // Read greeting + let mut greeting = [0u8; 3]; + stream.read_exact(&mut greeting).unwrap(); + // Reply: accept no-auth + stream.write_all(&[0x05, 0x00]).unwrap(); + stream.flush().unwrap(); + // Client should disconnect after domain length check fails + }); + + let mut stream = std::net::TcpStream::connect(addr).unwrap(); + let long_domain = "a".repeat(256); + let result = proxy.socks5_handshake_sync(&mut stream, &long_domain, 80); + assert!(result.is_err()); + + let _ = server.join(); + } + + /// Mock SOCKS5 server that expects username/password auth (RFC 1929). + fn mock_socks5_server_with_auth( + listener: &TcpListener, + expected_user: &str, + expected_pass: &str, + ) -> (String, u16, bool) { + let (mut stream, _) = listener.accept().unwrap(); + + // 1. Read greeting (should request method 0x02) + let mut greeting = [0u8; 3]; + stream.read_exact(&mut greeting).unwrap(); + assert_eq!(greeting[0], 0x05); + assert_eq!(greeting[1], 0x01); + assert_eq!(greeting[2], 0x02, "should request username/password auth"); + + // Accept method 0x02 + stream.write_all(&[0x05, 0x02]).unwrap(); + stream.flush().unwrap(); + + // 2. Read RFC 1929 auth request + let mut ver = [0u8; 1]; + stream.read_exact(&mut ver).unwrap(); + assert_eq!(ver[0], 0x01, "sub-negotiation version"); + + let mut ulen = [0u8; 1]; + stream.read_exact(&mut ulen).unwrap(); + let mut user = vec![0u8; ulen[0] as usize]; + stream.read_exact(&mut user).unwrap(); + + let mut plen = [0u8; 1]; + stream.read_exact(&mut plen).unwrap(); + let mut pass = vec![0u8; plen[0] as usize]; + stream.read_exact(&mut pass).unwrap(); + + let user_str = String::from_utf8(user).unwrap(); + let pass_str = String::from_utf8(pass).unwrap(); + let auth_ok = user_str == expected_user && pass_str == expected_pass; + + // Reply: 0x00 = success, 0x01 = failure + stream.write_all(&[0x01, if auth_ok { 0x00 } else { 0x01 }]).unwrap(); + stream.flush().unwrap(); + + if !auth_ok { + return (user_str, 0, false); + } + + // 3. Read connect request + let mut header = [0u8; 4]; + stream.read_exact(&mut header).unwrap(); + let mut len = [0u8; 1]; + stream.read_exact(&mut len).unwrap(); + let mut domain = vec![0u8; len[0] as usize]; + stream.read_exact(&mut domain).unwrap(); + let host = String::from_utf8(domain).unwrap(); + let mut port_bytes = [0u8; 2]; + stream.read_exact(&mut port_bytes).unwrap(); + let port = ((port_bytes[0] as u16) << 8) | port_bytes[1] as u16; + + // 4. Reply success + stream.write_all(&[ + 0x05, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ]).unwrap(); + stream.flush().unwrap(); + + (host, port, true) + } + + #[test] + fn handshake_with_credentials() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let proxy = Proxy::new_socks5_with_credentials( + format!("127.0.0.1:{}", addr.port()), "session-42", "x" + ).unwrap(); + + let server = std::thread::spawn(move || { + mock_socks5_server_with_auth(&listener, "session-42", "x") + }); + + let mut stream = std::net::TcpStream::connect(addr).unwrap(); + let result = proxy.socks5_handshake_sync(&mut stream, "example.com", 443); + assert!(result.is_ok()); + + let (host, port, auth_ok) = server.join().unwrap(); + assert!(auth_ok); + assert_eq!(host, "example.com"); + assert_eq!(port, 443); + } + + #[test] + fn handshake_credentials_rejected() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let proxy = Proxy::new_socks5_with_credentials( + format!("127.0.0.1:{}", addr.port()), "wrong-user", "wrong-pass" + ).unwrap(); + + let server = std::thread::spawn(move || { + mock_socks5_server_with_auth(&listener, "right-user", "right-pass") + }); + + let mut stream = std::net::TcpStream::connect(addr).unwrap(); + let result = proxy.socks5_handshake_sync(&mut stream, "example.com", 443); + assert!(result.is_err()); + + server.join().unwrap(); + } + + #[test] + fn handshake_no_auth_skips_credentials() { + // Proxy without credentials should use method 0x00 (no auth) + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let proxy = Proxy::new_socks5(format!("127.0.0.1:{}", addr.port())).unwrap(); + + let server = std::thread::spawn(move || { + mock_socks5_server(&listener, 0x00) + }); + + let mut stream = std::net::TcpStream::connect(addr).unwrap(); + let result = proxy.socks5_handshake_sync(&mut stream, "test.com", 80); + assert!(result.is_ok()); + + server.join().unwrap(); + } + } }