| | |
| | |
| | |
| |
|
| | 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; |
| |
|
| | |
| | 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 { |
| | |
| | pub host: String, |
| |
|
| | |
| | pub port: u16, |
| |
|
| | |
| | |
| | |
| | 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) |
| | } |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | #[derive(Debug, Clone, PartialEq, Eq)] |
| | #[expect(clippy::large_enum_variant)] |
| | pub enum ProxyConfig { |
| | |
| | Http(HttpConfig), |
| |
|
| | |
| | Https(HttpConfig), |
| |
|
| | |
| | Socks5(Socks5Config), |
| |
|
| | |
| | Shadowsocks(ShadowsocksConfig), |
| | } |
| |
|
| | |
| | fn http_connect_request(host: &str, port: u16, auth: Option<(&str, &str)>) -> String { |
| | |
| | |
| | |
| | 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 |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | async fn http_tunnel<T>(mut conn: T, host: &str, port: u16, auth: Option<(&str, &str)>) -> Result<T> |
| | where |
| | T: AsyncReadExt + AsyncWriteExt + Unpin, |
| | { |
| | |
| | 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") { |
| | |
| | break res; |
| | } |
| | }; |
| |
|
| | |
| | |
| | if !res.starts_with(b"HTTP/") { |
| | bail!("Unexpected HTTP CONNECT response: {res:?}"); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | let status_code = res |
| | .get(9..12) |
| | .context("HTTP status line does not contain a status code")?; |
| |
|
| | |
| | |
| | if status_code == b"407" { |
| | Err(format_err!("Proxy Authentication Required")) |
| | } else if status_code.starts_with(b"2") { |
| | |
| | Ok(conn) |
| | } else { |
| | Err(format_err!( |
| | "Failed to establish HTTP CONNECT tunnel: {res:?}" |
| | )) |
| | } |
| | } |
| |
|
| | impl ProxyConfig { |
| | |
| | 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)) |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | "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:?}")), |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | 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(), |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | async fn migrate_socks_config(sql: &Sql) -> Result<()> { |
| | if sql.get_raw_config("proxy_url").await?.is_none() { |
| | |
| | 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(()) |
| | } |
| |
|
| | |
| | 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)) |
| | } |
| |
|
| | |
| | |
| | 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() { |
| | |
| | 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()); |
| |
|
| | |
| | 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; |
| |
|
| | |
| | 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?; |
| | |
| | assert_eq!(proxy_config, None); |
| |
|
| | assert_eq!( |
| | t.get_config(Config::ProxyUrl).await?.unwrap(), |
| | "socks5://127.0.0.1:9050" |
| | ); |
| | Ok(()) |
| | } |
| |
|
| | |
| | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| | async fn test_socks5_migration_unconfigured() -> Result<()> { |
| | let t = TestContext::new().await; |
| |
|
| | |
| | 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(()) |
| | } |
| |
|
| | |
| | #[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?; |
| |
|
| | |
| | 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(()) |
| | } |
| | } |
| |
|