mirror of
https://github.com/chatmail/core.git
synced 2026-04-17 13:36:30 +03:00
Many clients don't send it currently, so it is unlikely that servers depend on it: https://mastodon.social/@cks/114690055923939576. For "implicit TLS", do not turn it off yet, it will serve as a fallback in case of rare server that needs it. If the server only supports STARTTLS and requires SNI then it is really weird, likely should not happen.
717 lines
24 KiB
Rust
717 lines
24 KiB
Rust
//! # Proxy support.
|
|
//!
|
|
//! Delta Chat supports HTTP(S) CONNECT, SOCKS5 and Shadowsocks protocols.
|
|
|
|
use std::fmt;
|
|
use std::pin::Pin;
|
|
|
|
use anyhow::{Context as _, Result, bail, format_err};
|
|
use base64::Engine;
|
|
use bytes::{BufMut, BytesMut};
|
|
use fast_socks5::AuthenticationMethod;
|
|
use fast_socks5::Socks5Command;
|
|
use fast_socks5::client::Socks5Stream;
|
|
use fast_socks5::util::target_addr::ToTargetAddr;
|
|
use percent_encoding::{NON_ALPHANUMERIC, percent_encode, utf8_percent_encode};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpStream;
|
|
use tokio_io_timeout::TimeoutStream;
|
|
use url::Url;
|
|
|
|
use crate::config::Config;
|
|
use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
|
|
use crate::context::Context;
|
|
use crate::net::connect_tcp;
|
|
use crate::net::session::SessionStream;
|
|
use crate::net::tls::wrap_rustls;
|
|
use crate::sql::Sql;
|
|
|
|
/// Default SOCKS5 port according to [RFC 1928](https://tools.ietf.org/html/rfc1928).
|
|
pub const DEFAULT_SOCKS_PORT: u16 = 1080;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ShadowsocksConfig {
|
|
pub server_config: shadowsocks::config::ServerConfig,
|
|
}
|
|
|
|
impl PartialEq for ShadowsocksConfig {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.server_config.to_url() == other.server_config.to_url()
|
|
}
|
|
}
|
|
|
|
impl Eq for ShadowsocksConfig {}
|
|
|
|
impl ShadowsocksConfig {
|
|
fn to_url(&self) -> String {
|
|
self.server_config.to_url()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct HttpConfig {
|
|
/// HTTP proxy host.
|
|
pub host: String,
|
|
|
|
/// HTTP proxy port.
|
|
pub port: u16,
|
|
|
|
/// Username and password for basic authentication.
|
|
///
|
|
/// If set, `Proxy-Authorization` header is sent.
|
|
pub user_password: Option<(String, String)>,
|
|
}
|
|
|
|
impl HttpConfig {
|
|
fn from_url(url: Url) -> Result<Self> {
|
|
let host = url
|
|
.host_str()
|
|
.context("HTTP proxy URL has no host")?
|
|
.to_string();
|
|
let port = url
|
|
.port_or_known_default()
|
|
.context("HTTP(S) URLs are guaranteed to return Some port")?;
|
|
let user_password = if let Some(password) = url.password() {
|
|
let username = percent_encoding::percent_decode_str(url.username())
|
|
.decode_utf8()
|
|
.context("HTTP(S) proxy username is not a valid UTF-8")?
|
|
.to_string();
|
|
let password = percent_encoding::percent_decode_str(password)
|
|
.decode_utf8()
|
|
.context("HTTP(S) proxy password is not a valid UTF-8")?
|
|
.to_string();
|
|
Some((username, password))
|
|
} else {
|
|
None
|
|
};
|
|
let http_config = HttpConfig {
|
|
host,
|
|
port,
|
|
user_password,
|
|
};
|
|
Ok(http_config)
|
|
}
|
|
|
|
fn to_url(&self, scheme: &str) -> String {
|
|
let host = utf8_percent_encode(&self.host, NON_ALPHANUMERIC_WITHOUT_DOT);
|
|
if let Some((user, password)) = &self.user_password {
|
|
let user = utf8_percent_encode(user, NON_ALPHANUMERIC);
|
|
let password = utf8_percent_encode(password, NON_ALPHANUMERIC);
|
|
format!("{scheme}://{user}:{password}@{host}:{}", self.port)
|
|
} else {
|
|
format!("{scheme}://{host}:{}", self.port)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Socks5Config {
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub user_password: Option<(String, String)>,
|
|
}
|
|
|
|
impl Socks5Config {
|
|
async fn connect(
|
|
&self,
|
|
context: &Context,
|
|
target_host: &str,
|
|
target_port: u16,
|
|
load_dns_cache: bool,
|
|
) -> Result<Socks5Stream<Pin<Box<TimeoutStream<TcpStream>>>>> {
|
|
let tcp_stream = connect_tcp(context, &self.host, self.port, load_dns_cache)
|
|
.await
|
|
.context("Failed to connect to SOCKS5 proxy")?;
|
|
|
|
let authentication_method = if let Some((username, password)) = self.user_password.as_ref()
|
|
{
|
|
Some(AuthenticationMethod::Password {
|
|
username: username.into(),
|
|
password: password.into(),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
let mut socks_stream =
|
|
Socks5Stream::use_stream(tcp_stream, authentication_method, Default::default()).await?;
|
|
let target_addr = (target_host, target_port).to_target_addr()?;
|
|
socks_stream
|
|
.request(Socks5Command::TCPConnect, target_addr)
|
|
.await?;
|
|
|
|
Ok(socks_stream)
|
|
}
|
|
|
|
fn to_url(&self) -> String {
|
|
let host = utf8_percent_encode(&self.host, NON_ALPHANUMERIC_WITHOUT_DOT);
|
|
if let Some((user, password)) = &self.user_password {
|
|
let user = utf8_percent_encode(user, NON_ALPHANUMERIC);
|
|
let password = utf8_percent_encode(password, NON_ALPHANUMERIC);
|
|
format!("socks5://{user}:{password}@{host}:{}", self.port)
|
|
} else {
|
|
format!("socks5://{host}:{}", self.port)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Configuration for the proxy through which all traffic
|
|
/// (except for iroh p2p connections)
|
|
/// will be sent.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
#[expect(clippy::large_enum_variant)]
|
|
pub enum ProxyConfig {
|
|
/// HTTP proxy.
|
|
Http(HttpConfig),
|
|
|
|
/// HTTPS proxy.
|
|
Https(HttpConfig),
|
|
|
|
/// SOCKS5 proxy.
|
|
Socks5(Socks5Config),
|
|
|
|
/// Shadowsocks proxy.
|
|
Shadowsocks(ShadowsocksConfig),
|
|
}
|
|
|
|
/// Constructs HTTP/1.1 `CONNECT` request for HTTP(S) proxy.
|
|
fn http_connect_request(host: &str, port: u16, auth: Option<(&str, &str)>) -> String {
|
|
// According to <https://datatracker.ietf.org/doc/html/rfc7230#section-5.4>
|
|
// clients MUST send `Host:` header in HTTP/1.1 requests,
|
|
// so repeat the host there.
|
|
let mut res = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n");
|
|
if let Some((username, password)) = auth {
|
|
res += "Proxy-Authorization: Basic ";
|
|
res += &base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"));
|
|
res += "\r\n";
|
|
}
|
|
res += "\r\n";
|
|
res
|
|
}
|
|
|
|
/// Sends HTTP/1.1 `CONNECT` request over given connection
|
|
/// to establish an HTTP tunnel.
|
|
///
|
|
/// Returns the same connection back so actual data can be tunneled over it.
|
|
async fn http_tunnel<T>(mut conn: T, host: &str, port: u16, auth: Option<(&str, &str)>) -> Result<T>
|
|
where
|
|
T: AsyncReadExt + AsyncWriteExt + Unpin,
|
|
{
|
|
// Send HTTP/1.1 CONNECT request.
|
|
let request = http_connect_request(host, port, auth);
|
|
conn.write_all(request.as_bytes()).await?;
|
|
|
|
let mut buffer = BytesMut::with_capacity(4096);
|
|
|
|
let res = loop {
|
|
if !buffer.has_remaining_mut() {
|
|
bail!("CONNECT response exceeded buffer size");
|
|
}
|
|
let n = conn.read_buf(&mut buffer).await?;
|
|
if n == 0 {
|
|
bail!("Unexpected end of CONNECT response");
|
|
}
|
|
|
|
let res = &buffer[..];
|
|
if res.ends_with(b"\r\n\r\n") {
|
|
// End of response is not reached, read more.
|
|
break res;
|
|
}
|
|
};
|
|
|
|
// Normally response looks like
|
|
// `HTTP/1.1 200 Connection established\r\n\r\n`.
|
|
if !res.starts_with(b"HTTP/") {
|
|
bail!("Unexpected HTTP CONNECT response: {res:?}");
|
|
}
|
|
|
|
// HTTP-version followed by space has fixed length
|
|
// according to RFC 7230:
|
|
// <https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2>
|
|
//
|
|
// Normally status line starts with `HTTP/1.1 `.
|
|
// We only care about 3-digit status code.
|
|
let status_code = res
|
|
.get(9..12)
|
|
.context("HTTP status line does not contain a status code")?;
|
|
|
|
// Interpret status code according to
|
|
// <https://datatracker.ietf.org/doc/html/rfc7231#section-6>.
|
|
if status_code == b"407" {
|
|
Err(format_err!("Proxy Authentication Required"))
|
|
} else if status_code.starts_with(b"2") {
|
|
// Success.
|
|
Ok(conn)
|
|
} else {
|
|
Err(format_err!(
|
|
"Failed to establish HTTP CONNECT tunnel: {res:?}"
|
|
))
|
|
}
|
|
}
|
|
|
|
impl ProxyConfig {
|
|
/// Creates a new proxy configuration by parsing given proxy URL.
|
|
pub fn from_url(url: &str) -> Result<Self> {
|
|
let url = Url::parse(url).context("Cannot parse proxy URL")?;
|
|
match url.scheme() {
|
|
"http" => {
|
|
let http_config = HttpConfig::from_url(url)?;
|
|
Ok(Self::Http(http_config))
|
|
}
|
|
"https" => {
|
|
let https_config = HttpConfig::from_url(url)?;
|
|
Ok(Self::Https(https_config))
|
|
}
|
|
"ss" => {
|
|
let server_config = shadowsocks::config::ServerConfig::from_url(url.as_str())?;
|
|
let shadowsocks_config = ShadowsocksConfig { server_config };
|
|
Ok(Self::Shadowsocks(shadowsocks_config))
|
|
}
|
|
|
|
// Because of `curl` convention,
|
|
// `socks5` URL scheme may be expected to resolve domain names locally
|
|
// with `socks5h` URL scheme meaning that hostnames are passed to the proxy.
|
|
// Resolving hostnames locally is not supported
|
|
// in Delta Chat when using a proxy
|
|
// to prevent DNS leaks.
|
|
// Because of this we do not distinguish
|
|
// between `socks5` and `socks5h`.
|
|
"socks5" => {
|
|
let host = url
|
|
.host_str()
|
|
.context("socks5 URL has no host")?
|
|
.to_string();
|
|
let port = url.port().unwrap_or(DEFAULT_SOCKS_PORT);
|
|
let user_password = if let Some(password) = url.password() {
|
|
let username = percent_encoding::percent_decode_str(url.username())
|
|
.decode_utf8()
|
|
.context("SOCKS5 username is not a valid UTF-8")?
|
|
.to_string();
|
|
let password = percent_encoding::percent_decode_str(password)
|
|
.decode_utf8()
|
|
.context("SOCKS5 password is not a valid UTF-8")?
|
|
.to_string();
|
|
Some((username, password))
|
|
} else {
|
|
None
|
|
};
|
|
let socks5_config = Socks5Config {
|
|
host,
|
|
port,
|
|
user_password,
|
|
};
|
|
Ok(Self::Socks5(socks5_config))
|
|
}
|
|
scheme => Err(format_err!("Unknown URL scheme {scheme:?}")),
|
|
}
|
|
}
|
|
|
|
/// Serializes proxy config into an URL.
|
|
///
|
|
/// This function can be used to normalize proxy URL
|
|
/// by parsing it and serializing back.
|
|
pub fn to_url(&self) -> String {
|
|
match self {
|
|
Self::Http(http_config) => http_config.to_url("http"),
|
|
Self::Https(http_config) => http_config.to_url("https"),
|
|
Self::Socks5(socks5_config) => socks5_config.to_url(),
|
|
Self::Shadowsocks(shadowsocks_config) => shadowsocks_config.to_url(),
|
|
}
|
|
}
|
|
|
|
/// Migrates legacy `socks5_host`, `socks5_port`, `socks5_user` and `socks5_password`
|
|
/// config into `proxy_url` if `proxy_url` is unset or empty.
|
|
///
|
|
/// Unsets `socks5_host`, `socks5_port`, `socks5_user` and `socks5_password` in any case.
|
|
async fn migrate_socks_config(sql: &Sql) -> Result<()> {
|
|
if sql.get_raw_config("proxy_url").await?.is_none() {
|
|
// Load legacy SOCKS5 settings.
|
|
if let Some(host) = sql
|
|
.get_raw_config("socks5_host")
|
|
.await?
|
|
.filter(|s| !s.is_empty())
|
|
{
|
|
let port: u16 = sql
|
|
.get_raw_config_int("socks5_port")
|
|
.await?
|
|
.unwrap_or(DEFAULT_SOCKS_PORT.into()) as u16;
|
|
let user = sql.get_raw_config("socks5_user").await?.unwrap_or_default();
|
|
let pass = sql
|
|
.get_raw_config("socks5_password")
|
|
.await?
|
|
.unwrap_or_default();
|
|
|
|
let mut proxy_url = "socks5://".to_string();
|
|
if !pass.is_empty() {
|
|
proxy_url += &percent_encode(user.as_bytes(), NON_ALPHANUMERIC).to_string();
|
|
proxy_url += ":";
|
|
proxy_url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
|
|
proxy_url += "@";
|
|
};
|
|
proxy_url += &host;
|
|
proxy_url += ":";
|
|
proxy_url += &port.to_string();
|
|
|
|
sql.set_raw_config("proxy_url", Some(&proxy_url)).await?;
|
|
} else {
|
|
sql.set_raw_config("proxy_url", Some("")).await?;
|
|
}
|
|
|
|
let socks5_enabled = sql.get_raw_config("socks5_enabled").await?;
|
|
sql.set_raw_config("proxy_enabled", socks5_enabled.as_deref())
|
|
.await?;
|
|
}
|
|
|
|
sql.set_raw_config("socks5_enabled", None).await?;
|
|
sql.set_raw_config("socks5_host", None).await?;
|
|
sql.set_raw_config("socks5_port", None).await?;
|
|
sql.set_raw_config("socks5_user", None).await?;
|
|
sql.set_raw_config("socks5_password", None).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Reads proxy configuration from the database.
|
|
pub async fn load(context: &Context) -> Result<Option<Self>> {
|
|
Self::migrate_socks_config(&context.sql)
|
|
.await
|
|
.context("Failed to migrate legacy SOCKS config")?;
|
|
|
|
let enabled = context.get_config_bool(Config::ProxyEnabled).await?;
|
|
if !enabled {
|
|
return Ok(None);
|
|
}
|
|
|
|
let proxy_url = context
|
|
.get_config(Config::ProxyUrl)
|
|
.await?
|
|
.unwrap_or_default();
|
|
let proxy_url = proxy_url
|
|
.split_once('\n')
|
|
.map_or(proxy_url.clone(), |(first_url, _rest)| {
|
|
first_url.to_string()
|
|
});
|
|
let proxy_config = Self::from_url(&proxy_url).context("Failed to parse proxy URL")?;
|
|
Ok(Some(proxy_config))
|
|
}
|
|
|
|
/// If `load_dns_cache` is true, loads cached DNS resolution results.
|
|
/// Use this only if the connection is going to be protected with TLS checks.
|
|
pub(crate) async fn connect(
|
|
&self,
|
|
context: &Context,
|
|
target_host: &str,
|
|
target_port: u16,
|
|
load_dns_cache: bool,
|
|
) -> Result<Box<dyn SessionStream>> {
|
|
match self {
|
|
ProxyConfig::Http(http_config) => {
|
|
let load_cache = false;
|
|
let tcp_stream = crate::net::connect_tcp(
|
|
context,
|
|
&http_config.host,
|
|
http_config.port,
|
|
load_cache,
|
|
)
|
|
.await?;
|
|
let auth = if let Some((username, password)) = &http_config.user_password {
|
|
Some((username.as_str(), password.as_str()))
|
|
} else {
|
|
None
|
|
};
|
|
let tunnel_stream = http_tunnel(tcp_stream, target_host, target_port, auth).await?;
|
|
Ok(Box::new(tunnel_stream))
|
|
}
|
|
ProxyConfig::Https(https_config) => {
|
|
let load_cache = true;
|
|
let tcp_stream = crate::net::connect_tcp(
|
|
context,
|
|
&https_config.host,
|
|
https_config.port,
|
|
load_cache,
|
|
)
|
|
.await?;
|
|
let use_sni = true;
|
|
let tls_stream = wrap_rustls(
|
|
&https_config.host,
|
|
https_config.port,
|
|
use_sni,
|
|
"",
|
|
tcp_stream,
|
|
&context.tls_session_store,
|
|
)
|
|
.await?;
|
|
let auth = if let Some((username, password)) = &https_config.user_password {
|
|
Some((username.as_str(), password.as_str()))
|
|
} else {
|
|
None
|
|
};
|
|
let tunnel_stream = http_tunnel(tls_stream, target_host, target_port, auth).await?;
|
|
Ok(Box::new(tunnel_stream))
|
|
}
|
|
ProxyConfig::Socks5(socks5_config) => {
|
|
let socks5_stream = socks5_config
|
|
.connect(context, target_host, target_port, load_dns_cache)
|
|
.await?;
|
|
Ok(Box::new(socks5_stream))
|
|
}
|
|
ProxyConfig::Shadowsocks(ShadowsocksConfig { server_config }) => {
|
|
let shadowsocks_context = shadowsocks::context::Context::new_shared(
|
|
shadowsocks::config::ServerType::Local,
|
|
);
|
|
|
|
let tcp_stream = {
|
|
let server_addr = server_config.addr();
|
|
let host = server_addr.host();
|
|
let port = server_addr.port();
|
|
connect_tcp(context, &host, port, load_dns_cache)
|
|
.await
|
|
.context("Failed to connect to Shadowsocks proxy")?
|
|
};
|
|
|
|
let shadowsocks_stream = shadowsocks::ProxyClientStream::from_stream(
|
|
shadowsocks_context,
|
|
tcp_stream,
|
|
server_config,
|
|
(target_host.to_string(), target_port),
|
|
);
|
|
|
|
Ok(Box::new(shadowsocks_stream))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Socks5Config {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(
|
|
f,
|
|
"host:{},port:{},user_password:{}",
|
|
self.host,
|
|
self.port,
|
|
if let Some(user_password) = self.user_password.clone() {
|
|
format!("user: {}, password: ***", user_password.0)
|
|
} else {
|
|
"user: None".to_string()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::Config;
|
|
use crate::test_utils::TestContext;
|
|
|
|
#[test]
|
|
fn test_socks5_url() {
|
|
let proxy_config = ProxyConfig::from_url("socks5://127.0.0.1:9050").unwrap();
|
|
assert_eq!(
|
|
proxy_config,
|
|
ProxyConfig::Socks5(Socks5Config {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 9050,
|
|
user_password: None
|
|
})
|
|
);
|
|
|
|
let proxy_config = ProxyConfig::from_url("socks5://foo:bar@127.0.0.1:9150").unwrap();
|
|
assert_eq!(
|
|
proxy_config,
|
|
ProxyConfig::Socks5(Socks5Config {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 9150,
|
|
user_password: Some(("foo".to_string(), "bar".to_string()))
|
|
})
|
|
);
|
|
|
|
let proxy_config = ProxyConfig::from_url("socks5://%66oo:b%61r@127.0.0.1:9150").unwrap();
|
|
assert_eq!(
|
|
proxy_config,
|
|
ProxyConfig::Socks5(Socks5Config {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 9150,
|
|
user_password: Some(("foo".to_string(), "bar".to_string()))
|
|
})
|
|
);
|
|
|
|
let proxy_config = ProxyConfig::from_url("socks5://127.0.0.1:80").unwrap();
|
|
assert_eq!(
|
|
proxy_config,
|
|
ProxyConfig::Socks5(Socks5Config {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 80,
|
|
user_password: None
|
|
})
|
|
);
|
|
|
|
let proxy_config = ProxyConfig::from_url("socks5://127.0.0.1").unwrap();
|
|
assert_eq!(
|
|
proxy_config,
|
|
ProxyConfig::Socks5(Socks5Config {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 1080,
|
|
user_password: None
|
|
})
|
|
);
|
|
|
|
let proxy_config = ProxyConfig::from_url("socks5://127.0.0.1:1080").unwrap();
|
|
assert_eq!(
|
|
proxy_config,
|
|
ProxyConfig::Socks5(Socks5Config {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 1080,
|
|
user_password: None
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_http_url() {
|
|
let proxy_config = ProxyConfig::from_url("http://127.0.0.1").unwrap();
|
|
assert_eq!(
|
|
proxy_config,
|
|
ProxyConfig::Http(HttpConfig {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 80,
|
|
user_password: None
|
|
})
|
|
);
|
|
|
|
let proxy_config = ProxyConfig::from_url("http://127.0.0.1:80").unwrap();
|
|
assert_eq!(
|
|
proxy_config,
|
|
ProxyConfig::Http(HttpConfig {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 80,
|
|
user_password: None
|
|
})
|
|
);
|
|
|
|
let proxy_config = ProxyConfig::from_url("http://127.0.0.1:443").unwrap();
|
|
assert_eq!(
|
|
proxy_config,
|
|
ProxyConfig::Http(HttpConfig {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 443,
|
|
user_password: None
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_https_url() {
|
|
let proxy_config = ProxyConfig::from_url("https://127.0.0.1").unwrap();
|
|
assert_eq!(
|
|
proxy_config,
|
|
ProxyConfig::Https(HttpConfig {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 443,
|
|
user_password: None
|
|
})
|
|
);
|
|
|
|
let proxy_config = ProxyConfig::from_url("https://127.0.0.1:80").unwrap();
|
|
assert_eq!(
|
|
proxy_config,
|
|
ProxyConfig::Https(HttpConfig {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 80,
|
|
user_password: None
|
|
})
|
|
);
|
|
|
|
let proxy_config = ProxyConfig::from_url("https://127.0.0.1:443").unwrap();
|
|
assert_eq!(
|
|
proxy_config,
|
|
ProxyConfig::Https(HttpConfig {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 443,
|
|
user_password: None
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_http_connect_request() {
|
|
assert_eq!(
|
|
http_connect_request("example.org", 143, Some(("aladdin", "opensesame"))),
|
|
"CONNECT example.org:143 HTTP/1.1\r\nHost: example.org:143\r\nProxy-Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l\r\n\r\n"
|
|
);
|
|
assert_eq!(
|
|
http_connect_request("example.net", 587, None),
|
|
"CONNECT example.net:587 HTTP/1.1\r\nHost: example.net:587\r\n\r\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_shadowsocks_url() {
|
|
// Example URL from <https://shadowsocks.org/doc/sip002.html>.
|
|
let proxy_config =
|
|
ProxyConfig::from_url("ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1")
|
|
.unwrap();
|
|
assert!(matches!(proxy_config, ProxyConfig::Shadowsocks(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_proxy_url() {
|
|
assert!(ProxyConfig::from_url("foobar://127.0.0.1:9050").is_err());
|
|
assert!(ProxyConfig::from_url("abc").is_err());
|
|
|
|
// This caused panic before shadowsocks 1.22.0.
|
|
assert!(ProxyConfig::from_url("ss://foo:bar@127.0.0.1:9999").is_err());
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_socks5_migration() -> Result<()> {
|
|
let t = TestContext::new().await;
|
|
|
|
// Test that config is migrated on attempt to load even if disabled.
|
|
t.set_config(Config::Socks5Host, Some("127.0.0.1")).await?;
|
|
t.set_config(Config::Socks5Port, Some("9050")).await?;
|
|
|
|
let proxy_config = ProxyConfig::load(&t).await?;
|
|
// Even though proxy is not enabled, config should be migrated.
|
|
assert_eq!(proxy_config, None);
|
|
|
|
assert_eq!(
|
|
t.get_config(Config::ProxyUrl).await?.unwrap(),
|
|
"socks5://127.0.0.1:9050"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
// Test SOCKS5 setting migration if proxy was never configured.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_socks5_migration_unconfigured() -> Result<()> {
|
|
let t = TestContext::new().await;
|
|
|
|
// Try to load config to trigger migration.
|
|
assert_eq!(ProxyConfig::load(&t).await?, None);
|
|
|
|
assert_eq!(t.get_config(Config::ProxyEnabled).await?, None);
|
|
assert_eq!(
|
|
t.get_config(Config::ProxyUrl).await?.unwrap(),
|
|
String::new()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
// Test SOCKS5 setting migration if SOCKS5 host is empty.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_socks5_migration_empty() -> Result<()> {
|
|
let t = TestContext::new().await;
|
|
|
|
t.set_config(Config::Socks5Host, Some("")).await?;
|
|
|
|
// Try to load config to trigger migration.
|
|
assert_eq!(ProxyConfig::load(&t).await?, None);
|
|
|
|
assert_eq!(t.get_config(Config::ProxyEnabled).await?, None);
|
|
assert_eq!(
|
|
t.get_config(Config::ProxyUrl).await?.unwrap(),
|
|
String::new()
|
|
);
|
|
Ok(())
|
|
}
|
|
}
|