//! # 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 { 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>>>> { 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 // 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(mut conn: T, host: &str, port: u16, auth: Option<(&str, &str)>) -> Result 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: // // // 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 // . 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 { 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> { 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> { 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 . 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(()) } }