use derive_deftly::Deftly;
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use serde::Deserialize;
use std::net::{IpAddr, SocketAddr};
use tor_config::PaddingLevel;
use tor_config::derive::prelude::*;
use tor_socksproto::SocksAuth;
use tor_socksproto::SocksVersion;
use url::{Host, Url};
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum ProxyProtocolParseError {
#[error("unsupported or missing proxy scheme: {0}")]
UnsupportedScheme(String),
#[error("password not supported for proxy scheme: {0}")]
UnsupportedPassword(String),
#[error("invalid proxy address: {0}")]
InvalidAddress(String),
#[error("missing or invalid port")]
InvalidPort,
#[error("invalid proxy URI format: {0}")]
InvalidFormat(String),
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct HttpConnectAuth {
pub username: String,
pub password: Option<String>,
}
#[derive(
Debug, Clone, Eq, PartialEq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay,
)]
#[non_exhaustive]
pub enum ProxyProtocol {
Socks {
version: SocksVersion,
auth: SocksAuth,
addr: SocketAddr,
},
HttpConnect {
addr: SocketAddr,
credentials: Option<HttpConnectAuth>,
},
}
impl std::str::FromStr for ProxyProtocol {
type Err = ProxyProtocolParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let url = Url::parse(s).map_err(|e| match e {
url::ParseError::InvalidPort => ProxyProtocolParseError::InvalidPort,
url::ParseError::InvalidIpv4Address
| url::ParseError::InvalidIpv6Address
| url::ParseError::EmptyHost
| url::ParseError::InvalidDomainCharacter
| url::ParseError::IdnaError => ProxyProtocolParseError::InvalidAddress(s.to_string()),
_ => ProxyProtocolParseError::InvalidFormat(s.to_string()),
})?;
let scheme_lower = url.scheme().to_ascii_lowercase();
if url.query().is_some() || url.fragment().is_some() {
return Err(ProxyProtocolParseError::InvalidFormat(s.to_string()));
}
let path = url.path();
if !path.is_empty() && path != "/" {
return Err(ProxyProtocolParseError::InvalidFormat(s.to_string()));
}
let port = url.port().ok_or(ProxyProtocolParseError::InvalidPort)?;
let host = url
.host()
.ok_or_else(|| ProxyProtocolParseError::InvalidAddress(s.to_string()))?;
let ip = match host {
Host::Ipv4(ip) => IpAddr::V4(ip),
Host::Ipv6(ip) => IpAddr::V6(ip),
Host::Domain(domain) => domain
.parse::<IpAddr>()
.map_err(|_| ProxyProtocolParseError::InvalidAddress(domain.to_string()))?,
};
let addr = SocketAddr::new(ip, port);
match scheme_lower.as_str() {
"http" => {
let user = url.username();
let pass = url.password();
if user.is_empty() && pass.is_some() {
return Err(ProxyProtocolParseError::InvalidFormat(
"password without username not supported".to_string(),
));
}
let credentials = if user.is_empty() {
None
} else {
let username = percent_decode_str(user)
.decode_utf8()
.map_err(|_| {
ProxyProtocolParseError::InvalidFormat(
"invalid UTF-8 in username".to_string(),
)
})?
.into_owned();
let password = pass
.map(|p| {
percent_decode_str(p).decode_utf8().map_err(|_| {
ProxyProtocolParseError::InvalidFormat(
"invalid UTF-8 in password".to_string(),
)
})
})
.transpose()?
.map(|s| s.into_owned());
Some(HttpConnectAuth { username, password })
};
Ok(ProxyProtocol::HttpConnect { addr, credentials })
}
"socks4" | "socks4a" | "socks5" | "socks5h" => {
let version = match scheme_lower.as_str() {
"socks4" | "socks4a" => SocksVersion::V4,
"socks5" | "socks5h" => SocksVersion::V5,
_ => unreachable!(),
};
let user = url.username();
let pass = url.password();
if version == SocksVersion::V4 && pass.is_some() {
return Err(ProxyProtocolParseError::UnsupportedPassword(
url.scheme().to_string(),
));
}
let user_decoded = percent_decode_str(user).decode_utf8().map_err(|_| {
ProxyProtocolParseError::InvalidFormat("invalid UTF-8 in username".to_string())
})?;
let pass_decoded = pass
.map(|p| {
percent_decode_str(p).decode_utf8().map_err(|_| {
ProxyProtocolParseError::InvalidFormat(
"invalid UTF-8 in password".to_string(),
)
})
})
.transpose()?;
let auth = if user.is_empty() && pass.is_none() {
SocksAuth::NoAuth
} else {
match version {
SocksVersion::V4 => SocksAuth::Socks4(user_decoded.as_bytes().to_vec()),
SocksVersion::V5 => {
let pass = pass_decoded.as_deref().unwrap_or("");
SocksAuth::Username(
user_decoded.as_bytes().to_vec(),
pass.as_bytes().to_vec(),
)
}
_ => SocksAuth::NoAuth,
}
};
Ok(ProxyProtocol::Socks {
version,
auth,
addr,
})
}
_ => Err(ProxyProtocolParseError::UnsupportedScheme(
url.scheme().to_string(),
)),
}
}
}
impl std::fmt::Display for ProxyProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProxyProtocol::Socks {
version,
auth,
addr,
} => {
match auth {
SocksAuth::NoAuth => write!(f, "{}://{}", version, addr),
SocksAuth::Socks4(user_id) => {
let user = String::from_utf8_lossy(user_id);
match encode_userinfo(*version, *addr, &user, None) {
Some((user_encoded, _)) => {
write!(f, "{}://{}@{}", version, user_encoded, addr)
}
None => write!(f, "{}://{}@{}", version, user, addr),
}
}
SocksAuth::Username(user, pass) => {
let user = String::from_utf8_lossy(user);
let pass = String::from_utf8_lossy(pass);
match encode_userinfo(*version, *addr, &user, Some(&pass)) {
Some((user_encoded, pass_encoded)) => {
let pass_encoded = pass_encoded.unwrap_or_default();
write!(
f,
"{}://{}:{}@{}",
version, user_encoded, pass_encoded, addr
)
}
None => write!(f, "{}://{}:{}@{}", version, user, pass, addr),
}
}
_ => write!(f, "{}://{}", version, addr),
}
}
ProxyProtocol::HttpConnect { addr, credentials } => {
if let Some(auth) = credentials {
let (user_encoded, pass_encoded) =
encode_userinfo_http(*addr, &auth.username, auth.password.as_deref())
.unwrap_or_else(|| {
debug_assert!(
false,
"encode_userinfo_http failed for addr={}, user={}",
addr, auth.username
);
let encoded_user = percent_encode_userinfo(&auth.username);
let encoded_pass =
auth.password.as_ref().map(|p| percent_encode_userinfo(p));
(encoded_user, encoded_pass)
});
if let Some(p) = pass_encoded {
write!(f, "http://{}:{}@{}", user_encoded, p, addr)
} else {
write!(f, "http://{}@{}", user_encoded, addr)
}
} else {
write!(f, "http://{}", addr)
}
}
}
}
}
impl ProxyProtocol {
pub fn is_loopback(&self) -> bool {
let addr = match self {
ProxyProtocol::Socks { addr, .. } => addr,
ProxyProtocol::HttpConnect { addr, .. } => addr,
};
addr.ip().is_loopback()
}
}
const USERINFO_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b':')
.add(b'@')
.add(b'/')
.add(b'?')
.add(b'#')
.add(b'[')
.add(b']');
fn percent_encode_userinfo(s: &str) -> String {
utf8_percent_encode(s, USERINFO_ENCODE_SET).to_string()
}
fn encode_userinfo_with_scheme(
scheme: &str,
addr: SocketAddr,
username: &str,
password: Option<&str>,
) -> Option<(String, Option<String>)> {
let url_str = format!("{}://{}", scheme, addr);
let mut url = Url::parse(&url_str).ok()?;
if url.set_username(username).is_err() {
return None;
}
if url.set_password(password).is_err() {
return None;
}
let user_encoded = url.username().to_string();
let pass_encoded = url.password().map(str::to_string);
Some((user_encoded, pass_encoded))
}
fn encode_userinfo_http(
addr: SocketAddr,
username: &str,
password: Option<&str>,
) -> Option<(String, Option<String>)> {
encode_userinfo_with_scheme("http", addr, username, password)
}
fn encode_userinfo(
version: SocksVersion,
addr: SocketAddr,
username: &str,
password: Option<&str>,
) -> Option<(String, Option<String>)> {
encode_userinfo_with_scheme(&version.to_string(), addr, username, password)
}
impl ProxyProtocol {
pub fn socks_no_auth(version: SocksVersion, addr: SocketAddr) -> Self {
ProxyProtocol::Socks {
version,
auth: SocksAuth::NoAuth,
addr,
}
}
}
#[allow(clippy::option_option)]
fn deserialize_outbound_proxy<'de, D>(
deserializer: D,
) -> Result<Option<Option<ProxyProtocol>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
match value {
None => Ok(None),
Some(s) => {
if s.trim().is_empty() {
return Ok(Some(None));
}
let parsed = s.parse().map_err(serde::de::Error::custom)?;
Ok(Some(Some(parsed)))
}
}
}
#[derive(Debug, Clone, Deftly, Eq, PartialEq)]
#[derive_deftly(TorConfig)]
pub struct ChannelConfig {
#[deftly(tor_config(default))]
pub(crate) padding: PaddingLevel,
#[deftly(tor_config(
default,
serde = r#" deserialize_with = "deserialize_outbound_proxy" "#
))]
pub(crate) outbound_proxy: Option<ProxyProtocol>,
}
impl ChannelConfig {
pub fn outbound_proxy(&self) -> Option<&ProxyProtocol> {
self.outbound_proxy.as_ref()
}
}
#[cfg(feature = "testing")]
impl ChannelConfig {
pub fn padding(&self) -> PaddingLevel {
self.padding
}
}
#[cfg(test)]
mod test {
#![allow(clippy::bool_assert_comparison)]
#![allow(clippy::clone_on_copy)]
#![allow(clippy::dbg_macro)]
#![allow(clippy::mixed_attributes_style)]
#![allow(clippy::print_stderr)]
#![allow(clippy::print_stdout)]
#![allow(clippy::single_char_pattern)]
#![allow(clippy::unwrap_used)]
#![allow(clippy::unchecked_time_subtraction)]
#![allow(clippy::useless_vec)]
#![allow(clippy::needless_pass_by_value)]
use super::*;
#[test]
fn channel_config() {
let config = ChannelConfig::default();
assert_eq!(PaddingLevel::Normal, config.padding);
}
#[test]
fn proxy_protocol_parse_socks5_basic() {
let p: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
match p {
ProxyProtocol::Socks {
version,
auth,
addr,
} => {
assert_eq!(version, SocksVersion::V5);
assert_eq!(auth, SocksAuth::NoAuth);
assert_eq!(addr, "127.0.0.1:1080".parse().unwrap());
}
ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
}
}
#[test]
fn proxy_protocol_parse_socks5_with_auth() {
let p: ProxyProtocol = "socks5://myuser:mypass@192.168.1.1:9050".parse().unwrap();
match p {
ProxyProtocol::Socks {
version,
auth,
addr,
} => {
assert_eq!(version, SocksVersion::V5);
assert_eq!(
auth,
SocksAuth::Username(b"myuser".to_vec(), b"mypass".to_vec())
);
assert_eq!(addr, "192.168.1.1:9050".parse().unwrap());
}
ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
}
}
#[test]
fn proxy_protocol_parse_socks4() {
let p: ProxyProtocol = "socks4://10.0.0.1:1080".parse().unwrap();
match p {
ProxyProtocol::Socks {
version,
auth,
addr,
} => {
assert_eq!(version, SocksVersion::V4);
assert_eq!(auth, SocksAuth::NoAuth);
assert_eq!(addr, "10.0.0.1:1080".parse().unwrap());
}
ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
}
}
#[test]
fn proxy_protocol_parse_socks4a() {
let p: ProxyProtocol = "socks4a://10.0.0.1:1080".parse().unwrap();
match p {
ProxyProtocol::Socks { version, auth, .. } => {
assert_eq!(version, SocksVersion::V4);
assert_eq!(auth, SocksAuth::NoAuth);
}
ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
}
}
#[test]
fn proxy_protocol_parse_ipv6() {
let p: ProxyProtocol = "socks5://[::1]:1080".parse().unwrap();
match p {
ProxyProtocol::Socks { addr, .. } => {
assert_eq!(addr, "[::1]:1080".parse().unwrap());
}
ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
}
}
#[test]
fn proxy_protocol_display_roundtrip() {
for uri in [
"socks5://127.0.0.1:1080",
"socks4://10.0.0.1:9050",
"socks5://user:pass@192.168.1.1:1080",
"socks5://[::1]:1080",
"http://127.0.0.1:8080",
"http://user:pass@192.168.1.1:3128",
] {
let p: ProxyProtocol = uri.parse().unwrap();
let s = p.to_string();
let p2: ProxyProtocol = s.parse().unwrap();
assert_eq!(p, p2, "Round-trip failed for: {}", uri);
}
}
#[test]
fn proxy_protocol_parse_errors() {
assert!("127.0.0.1:1080".parse::<ProxyProtocol>().is_err());
assert!("invalid://127.0.0.1:1080".parse::<ProxyProtocol>().is_err());
assert!("socks5://127.0.0.1".parse::<ProxyProtocol>().is_err());
assert!("socks5://not-an-ip:1080".parse::<ProxyProtocol>().is_err());
assert!(
"socks4://user:pass@10.0.0.1:1080"
.parse::<ProxyProtocol>()
.is_err()
);
}
#[test]
fn proxy_protocol_case_insensitive() {
let p1: ProxyProtocol = "SOCKS5://127.0.0.1:1080".parse().unwrap();
let p2: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
let p3: ProxyProtocol = "SoCkS5://127.0.0.1:1080".parse().unwrap();
assert_eq!(p1, p2);
assert_eq!(p2, p3);
}
#[test]
fn proxy_protocol_parse_socks5h() {
let p: ProxyProtocol = "socks5h://127.0.0.1:1080".parse().unwrap();
match p {
ProxyProtocol::Socks { version, auth, .. } => {
assert_eq!(version, SocksVersion::V5);
assert_eq!(auth, SocksAuth::NoAuth);
}
ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
}
}
#[test]
fn proxy_protocol_parse_socks4_user_only() {
let p: ProxyProtocol = "socks4://myuser@10.0.0.1:1080".parse().unwrap();
match p {
ProxyProtocol::Socks {
version,
auth,
addr,
} => {
assert_eq!(version, SocksVersion::V4);
assert_eq!(auth, SocksAuth::Socks4(b"myuser".to_vec()));
assert_eq!(addr, "10.0.0.1:1080".parse().unwrap());
}
ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
}
}
#[test]
fn proxy_protocol_parse_socks5_user_only() {
let p: ProxyProtocol = "socks5://myuser@192.168.1.1:9050".parse().unwrap();
match p {
ProxyProtocol::Socks {
version,
auth,
addr,
} => {
assert_eq!(version, SocksVersion::V5);
assert_eq!(auth, SocksAuth::Username(b"myuser".to_vec(), b"".to_vec()));
assert_eq!(addr, "192.168.1.1:9050".parse().unwrap());
}
ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
}
}
#[test]
fn proxy_protocol_percent_encoding_roundtrip() {
let p = ProxyProtocol::Socks {
version: SocksVersion::V5,
auth: SocksAuth::Username(b"user@domain".to_vec(), b"pass:word".to_vec()),
addr: "127.0.0.1:1080".parse().unwrap(),
};
let s = p.to_string();
assert!(s.contains("%40"), "@ should be encoded as %40");
assert!(
s.contains("%3A") || s.contains("%3a"),
": in password should be encoded"
);
let p2: ProxyProtocol = s.parse().unwrap();
assert_eq!(p, p2, "Round-trip failed for percent-encoded URI");
}
#[test]
fn proxy_protocol_socks4_user_roundtrip() {
let uri = "socks4://testuser@10.0.0.1:1080";
let p: ProxyProtocol = uri.parse().unwrap();
let s = p.to_string();
let p2: ProxyProtocol = s.parse().unwrap();
assert_eq!(p, p2, "SOCKS4 user-only round-trip failed");
}
#[test]
fn proxy_protocol_parse_http_connect_basic() {
let p: ProxyProtocol = "http://127.0.0.1:8080".parse().unwrap();
match p {
ProxyProtocol::HttpConnect { addr, credentials } => {
assert_eq!(addr, "127.0.0.1:8080".parse().unwrap());
assert!(credentials.is_none());
}
_ => panic!("expected HttpConnect"),
}
}
#[test]
fn proxy_protocol_parse_http_connect_with_auth() {
let p: ProxyProtocol = "http://myuser:mypass@192.168.1.1:3128".parse().unwrap();
match p {
ProxyProtocol::HttpConnect { addr, credentials } => {
assert_eq!(addr, "192.168.1.1:3128".parse().unwrap());
let auth = credentials.expect("expected credentials");
assert_eq!(auth.username, "myuser");
assert_eq!(auth.password.as_deref(), Some("mypass"));
}
_ => panic!("expected HttpConnect"),
}
}
#[test]
fn proxy_protocol_parse_http_connect_ipv6() {
let p: ProxyProtocol = "http://[::1]:8080".parse().unwrap();
match p {
ProxyProtocol::HttpConnect { addr, .. } => {
assert_eq!(addr, "[::1]:8080".parse().unwrap());
}
_ => panic!("expected HttpConnect"),
}
}
#[test]
fn proxy_protocol_parse_http_connect_user_only() {
let p: ProxyProtocol = "http://myuser@127.0.0.1:8080".parse().unwrap();
match p {
ProxyProtocol::HttpConnect { credentials, .. } => {
let auth = credentials.expect("expected credentials");
assert_eq!(auth.username, "myuser");
assert!(auth.password.is_none());
}
_ => panic!("expected HttpConnect"),
}
}
#[test]
fn proxy_protocol_reject_password_only() {
let result: Result<ProxyProtocol, _> = "http://:secretpass@127.0.0.1:8080".parse();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("password without username"),
"error should mention password without username: {}",
err
);
}
#[test]
fn proxy_protocol_is_loopback() {
let p: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
assert!(p.is_loopback());
let p: ProxyProtocol = "http://[::1]:8080".parse().unwrap();
assert!(p.is_loopback());
let p: ProxyProtocol = "socks5://10.0.0.1:1080".parse().unwrap();
assert!(!p.is_loopback());
let p: ProxyProtocol = "http://[2001:db8::1]:8080".parse().unwrap();
assert!(!p.is_loopback());
}
#[test]
fn proxy_protocol_http_connect_percent_encoding_roundtrip() {
let p = ProxyProtocol::HttpConnect {
addr: "127.0.0.1:8080".parse().unwrap(),
credentials: Some(HttpConnectAuth {
username: "user@domain".to_string(),
password: Some("pass:word".to_string()),
}),
};
let s = p.to_string();
assert!(s.contains("%40"), "@ should be encoded as %40: {}", s);
assert!(
s.contains("%3A") || s.contains("%3a"),
": in password should be encoded: {}",
s
);
let p2: ProxyProtocol = s.parse().unwrap();
assert_eq!(
p, p2,
"Round-trip failed for percent-encoded HTTP CONNECT URI"
);
}
#[test]
fn proxy_protocol_http_connect_parse_percent_encoded() {
let p: ProxyProtocol = "http://user%40domain:pass%3Aword@127.0.0.1:8080"
.parse()
.unwrap();
match p {
ProxyProtocol::HttpConnect { addr, credentials } => {
assert_eq!(addr, "127.0.0.1:8080".parse().unwrap());
let auth = credentials.expect("expected credentials");
assert_eq!(
auth.username, "user@domain",
"username should decode %40 to @"
);
assert_eq!(
auth.password.as_deref(),
Some("pass:word"),
"password should decode %3A to :"
);
}
_ => panic!("expected HttpConnect"),
}
}
}