diff --git a/.gitignore b/.gitignore index f995f49d0..4dd065e7a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ config.yaml cache.db Country.mmdb ruleset/ +geosite.dat rust-project.json diff --git a/README.md b/README.md index d935e226f..5fc34972f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A custom protocol, rule based network proxy software. - 🌈 Flexible traffic routing rules based off source/destination IP/Domain/GeoIP etc. - 📦 Local anti spoofing DNS with support of UDP/TCP/DoH/DoT remote. - 🛡 Run as an HTTP/Socks5 proxy, or utun device as a home network gateway. -- ⚙️ Shadowsocks/Trojan/Vmess/Wireguard(userspace)/Tor/Tuic outbound support with different underlying trasports(gRPC/TLS/H2/WebSocket/etc.). +- ⚙️ Shadowsocks/Trojan/Vmess/Wireguard(userspace)/Tor/Tuic/Socks5(TCP/UDP) outbound support with different underlying trasports(gRPC/TLS/H2/WebSocket/etc.). - 🌍 Dynamic remote rule/proxy loader. - 🎵 Tracing with Jaeger diff --git a/clash/tests/data/config/socks5.yaml b/clash/tests/data/config/socks5.yaml new file mode 100644 index 000000000..33257fce0 --- /dev/null +++ b/clash/tests/data/config/socks5.yaml @@ -0,0 +1,39 @@ +--- +port: 8888 +socks-port: 8889 +mixed-port: 8899 + +mode: rule +log-level: debug +external-controller: 127.0.0.1:6170 + + +proxies: + - name: "socks5-noauth" + type: socks5 + server: 10.0.0.13 + port: 10800 + udp: true + + - name: "socks5-auth" + type: socks5 + server: 10.0.0.13 + port: 10801 + username: user + password: password + udp: true + + - name: "socks5-tls" + type: socks5 + server: 10.0.0.13 + port: 10802 + username: user + password: password + tls: true + udp: true + skip-cert-verify: true +rules: +# - MATCH, socks5-noauth +# - MATCH, socks5-auth + - MATCH, socks5-tls +... diff --git a/clash_lib/src/app/outbound/manager.rs b/clash_lib/src/app/outbound/manager.rs index f59c024d7..e3f1a3459 100644 --- a/clash_lib/src/app/outbound/manager.rs +++ b/clash_lib/src/app/outbound/manager.rs @@ -203,6 +203,10 @@ impl OutboundManager { handlers.insert(s.name.clone(), s.try_into()?); } + OutboundProxyProtocol::Socks5(s) => { + handlers.insert(s.name.clone(), s.try_into()?); + } + OutboundProxyProtocol::Vmess(v) => { handlers.insert(v.name.clone(), v.try_into()?); } @@ -222,9 +226,6 @@ impl OutboundManager { OutboundProxyProtocol::Tuic(tuic) => { handlers.insert(tuic.name.clone(), tuic.try_into()?); } - p => { - unimplemented!("proto {} not supported yet", p); - } } } diff --git a/clash_lib/src/app/remote_content_manager/providers/proxy_provider/proxy_set_provider.rs b/clash_lib/src/app/remote_content_manager/providers/proxy_provider/proxy_set_provider.rs index b80df2133..f6b2d75b9 100644 --- a/clash_lib/src/app/remote_content_manager/providers/proxy_provider/proxy_set_provider.rs +++ b/clash_lib/src/app/remote_content_manager/providers/proxy_provider/proxy_set_provider.rs @@ -114,9 +114,7 @@ impl ProxySetProvider { Ok(reject::Handler::new()) } OutboundProxyProtocol::Ss(s) => s.try_into(), - OutboundProxyProtocol::Socks5(_) => { - todo!("socks5 not supported yet") - } + OutboundProxyProtocol::Socks5(s) => s.try_into(), OutboundProxyProtocol::Trojan(tr) => tr.try_into(), OutboundProxyProtocol::Vmess(vm) => vm.try_into(), OutboundProxyProtocol::Wireguard(wg) => wg.try_into(), diff --git a/clash_lib/src/common/utils.rs b/clash_lib/src/common/utils.rs index b83108d1f..34216c7d1 100644 --- a/clash_lib/src/common/utils.rs +++ b/clash_lib/src/common/utils.rs @@ -58,10 +58,18 @@ pub fn md5(bytes: &[u8]) -> Vec { hasher.finalize().to_vec() } +/// Default value true for bool on serde +/// use this if you don't want do deal with Option pub fn default_bool_true() -> bool { true } +/// Default value false for bool on serde +/// use this if you don't want do deal with Option +pub fn default_bool_false() -> bool { + false +} + #[async_recursion] pub async fn download

( url: &str, diff --git a/clash_lib/src/config/internal/proxy.rs b/clash_lib/src/config/internal/proxy.rs index 6ab8df732..43613ec53 100644 --- a/clash_lib/src/config/internal/proxy.rs +++ b/clash_lib/src/config/internal/proxy.rs @@ -1,4 +1,8 @@ -use crate::{common::utils::default_bool_true, config::utils, Error}; +use crate::{ + common::utils::{default_bool_false, default_bool_true}, + config::utils, + Error, +}; use serde::{de::value::MapDeserializer, Deserialize}; use serde_yaml::Value; use std::{ @@ -116,6 +120,7 @@ impl Display for OutboundProxyProtocol { } #[derive(serde::Serialize, serde::Deserialize, Debug, Default)] +#[serde(rename_all = "kebab-case")] pub struct OutboundShadowsocks { pub name: String, pub server: String, @@ -125,23 +130,28 @@ pub struct OutboundShadowsocks { #[serde(default = "default_bool_true")] pub udp: bool, pub plugin: Option, - #[serde(alias = "plugin-opts")] pub plugin_opts: Option>, } #[derive(serde::Serialize, serde::Deserialize, Debug, Default)] +#[serde(rename_all = "kebab-case")] pub struct OutboundSocks5 { pub name: String, pub server: String, pub port: u16, pub username: Option, pub password: Option, + #[serde(default = "default_bool_false")] pub tls: bool, - pub skip_cert_verity: bool, + pub sni: Option, + #[serde(default = "default_bool_false")] + pub skip_cert_verify: bool, + #[serde(default = "default_bool_true")] pub udp: bool, } #[derive(serde::Serialize, serde::Deserialize, Debug, Default)] +#[serde(rename_all = "kebab-case")] pub struct WsOpt { pub path: Option, pub headers: Option>, diff --git a/clash_lib/src/proxy/converters/mod.rs b/clash_lib/src/proxy/converters/mod.rs index 9d4cfc520..b02ade789 100644 --- a/clash_lib/src/proxy/converters/mod.rs +++ b/clash_lib/src/proxy/converters/mod.rs @@ -1,4 +1,5 @@ pub mod shadowsocks; +pub mod socks5; pub mod tor; pub mod trojan; pub mod tuic; diff --git a/clash_lib/src/proxy/converters/socks5.rs b/clash_lib/src/proxy/converters/socks5.rs new file mode 100644 index 000000000..c262fd475 --- /dev/null +++ b/clash_lib/src/proxy/converters/socks5.rs @@ -0,0 +1,35 @@ +use crate::{ + config::internal::proxy::OutboundSocks5, + proxy::{ + socks::{Handler, HandlerOptions}, + AnyOutboundHandler, + }, +}; + +impl TryFrom for AnyOutboundHandler { + type Error = crate::Error; + + fn try_from(value: OutboundSocks5) -> Result { + (&value).try_into() + } +} + +impl TryFrom<&OutboundSocks5> for AnyOutboundHandler { + type Error = crate::Error; + + fn try_from(s: &OutboundSocks5) -> Result { + let h = Handler::new(HandlerOptions { + name: s.name.to_owned(), + common_opts: Default::default(), + server: s.server.to_owned(), + port: s.port, + user: s.username.clone(), + password: s.password.clone(), + udp: s.udp, + tls: s.tls, + sni: s.sni.clone().unwrap_or(s.server.to_owned()), + skip_cert_verify: s.skip_cert_verify, + }); + Ok(h) + } +} diff --git a/clash_lib/src/proxy/converters/trojan.rs b/clash_lib/src/proxy/converters/trojan.rs index 9eb2f38cf..e81e6bc0b 100644 --- a/clash_lib/src/proxy/converters/trojan.rs +++ b/clash_lib/src/proxy/converters/trojan.rs @@ -4,7 +4,7 @@ use crate::{ config::internal::proxy::OutboundTrojan, proxy::{ options::{GrpcOption, WsOption}, - trojan::{Handler, Opts, Transport}, + trojan::{Handler, HandlerOptions, Transport}, AnyOutboundHandler, CommonOption, }, Error, @@ -27,7 +27,7 @@ impl TryFrom<&OutboundTrojan> for AnyOutboundHandler { warn!("skipping TLS cert verification for {}", s.server); } - let h = Handler::new(Opts { + let h = Handler::new(HandlerOptions { name: s.name.to_owned(), common_opts: CommonOption::default(), server: s.server.to_owned(), diff --git a/clash_lib/src/proxy/converters/wireguard.rs b/clash_lib/src/proxy/converters/wireguard.rs index c351b4734..48a53ab53 100644 --- a/clash_lib/src/proxy/converters/wireguard.rs +++ b/clash_lib/src/proxy/converters/wireguard.rs @@ -3,7 +3,7 @@ use ipnet::IpNet; use crate::{ config::internal::proxy::OutboundWireguard, proxy::{ - wg::{Handler, HandlerOpts}, + wg::{Handler, HandlerOptions}, AnyOutboundHandler, }, Error, @@ -21,7 +21,7 @@ impl TryFrom<&OutboundWireguard> for AnyOutboundHandler { type Error = crate::Error; fn try_from(s: &OutboundWireguard) -> Result { - let h = Handler::new(HandlerOpts { + let h = Handler::new(HandlerOptions { name: s.name.to_owned(), server: s.server.to_owned(), port: s.port, diff --git a/clash_lib/src/proxy/mod.rs b/clash_lib/src/proxy/mod.rs index ca9bb9a65..d66baa304 100644 --- a/clash_lib/src/proxy/mod.rs +++ b/clash_lib/src/proxy/mod.rs @@ -122,6 +122,7 @@ pub enum OutboundType { WireGuard, Tor, Tuic, + Socks5, #[serde(rename = "URLTest")] UrlTest, @@ -143,11 +144,14 @@ impl Display for OutboundType { OutboundType::WireGuard => write!(f, "WireGuard"), OutboundType::Tor => write!(f, "Tor"), OutboundType::Tuic => write!(f, "Tuic"), + OutboundType::Socks5 => write!(f, "Socks5"), + OutboundType::UrlTest => write!(f, "URLTest"), OutboundType::Selector => write!(f, "Selector"), OutboundType::Relay => write!(f, "Relay"), OutboundType::LoadBalance => write!(f, "LoadBalance"), OutboundType::Fallback => write!(f, "Fallback"), + OutboundType::Direct => write!(f, "Direct"), OutboundType::Reject => write!(f, "Reject"), } diff --git a/clash_lib/src/proxy/socks/inbound/mod.rs b/clash_lib/src/proxy/socks/inbound/mod.rs index 3e99a2f83..ddfa682cb 100644 --- a/clash_lib/src/proxy/socks/inbound/mod.rs +++ b/clash_lib/src/proxy/socks/inbound/mod.rs @@ -15,32 +15,6 @@ use tracing::warn; pub use datagram::Socks5UDPCodec; -pub const SOCKS5_VERSION: u8 = 0x05; - -pub(crate) mod auth_methods { - pub const NO_AUTH: u8 = 0x00; - pub const USER_PASS: u8 = 0x02; - pub const NO_METHODS: u8 = 0xff; -} - -pub(crate) mod response_code { - pub const SUCCEEDED: u8 = 0x00; - pub const FAILURE: u8 = 0x01; - // pub const RULE_FAILURE: u8 = 0x02; - // pub const NETWORK_UNREACHABLE: u8 = 0x03; - // pub const HOST_UNREACHABLE: u8 = 0x04; - // pub const CONNECTION_REFUSED: u8 = 0x05; - // pub const TTL_EXPIRED: u8 = 0x06; - pub const COMMAND_NOT_SUPPORTED: u8 = 0x07; - // pub const ADDR_TYPE_NOT_SUPPORTED: u8 = 0x08; -} - -pub(crate) mod socks_command { - pub const CONNECT: u8 = 0x01; - // pub const BIND: u8 = 0x02; - pub const UDP_ASSOCIATE: u8 = 0x3; -} - pub struct Listener { addr: SocketAddr, dispatcher: Arc, diff --git a/clash_lib/src/proxy/socks/inbound/stream.rs b/clash_lib/src/proxy/socks/inbound/stream.rs index fec380b63..d6034986e 100644 --- a/clash_lib/src/proxy/socks/inbound/stream.rs +++ b/clash_lib/src/proxy/socks/inbound/stream.rs @@ -2,9 +2,9 @@ use crate::{ common::{auth::ThreadSafeAuthenticator, errors::new_io_error}, proxy::{ datagram::InboundUdp, - socks::inbound::{ - auth_methods, datagram::Socks5UDPCodec, response_code, socks_command, - SOCKS5_VERSION, + socks::{ + socks5::{auth_methods, response_code, socks_command}, + Socks5UDPCodec, SOCKS5_VERSION, }, utils::new_udp_socket, }, @@ -31,6 +31,7 @@ pub async fn handle_tcp<'a>( // handshake let mut buf = BytesMut::new(); { + // TODO: move this to a function buf.resize(2, 0); s.read_exact(&mut buf[..]).await?; diff --git a/clash_lib/src/proxy/socks/mod.rs b/clash_lib/src/proxy/socks/mod.rs index 3010d771d..e26f08476 100644 --- a/clash_lib/src/proxy/socks/mod.rs +++ b/clash_lib/src/proxy/socks/mod.rs @@ -1,3 +1,7 @@ mod inbound; +mod outbound; +mod socks5; -pub use inbound::{handle_tcp, Listener, Socks5UDPCodec, SOCKS5_VERSION}; +pub use inbound::{handle_tcp, Listener, Socks5UDPCodec}; +pub use outbound::{Handler, HandlerOptions}; +pub use socks5::SOCKS5_VERSION; diff --git a/clash_lib/src/proxy/socks/outbound/datagram.rs b/clash_lib/src/proxy/socks/outbound/datagram.rs new file mode 100644 index 000000000..a037b40cc --- /dev/null +++ b/clash_lib/src/proxy/socks/outbound/datagram.rs @@ -0,0 +1,122 @@ +use std::{ + net::SocketAddr, + pin::Pin, + task::{Context, Poll}, +}; + +use futures::{Sink, SinkExt, Stream, StreamExt}; +use tokio::net::UdpSocket; +use tokio_util::udp::UdpFramed; +use tracing::{error, trace}; + +use crate::{ + proxy::{datagram::UdpPacket, socks::Socks5UDPCodec, AnyStream}, + session::SocksAddr, +}; + +pub(crate) struct Socks5Datagram { + // hold the socket to keep it alive and drop it when this is dropped + _socket: AnyStream, + remote: SocketAddr, + inner: UdpFramed, +} + +impl Socks5Datagram { + pub(crate) fn new( + socket: AnyStream, + remote: SocketAddr, + udp_socket: UdpSocket, + ) -> Self { + let framed = UdpFramed::new(udp_socket, Socks5UDPCodec); + + Self { + _socket: socket, + remote, + inner: framed, + } + } +} + +impl Drop for Socks5Datagram { + fn drop(&mut self) { + // this should drop the inner socket too. + // https://datatracker.ietf.org/doc/html/rfc1928 + // A UDP association terminates when the TCP connection that the UDP + // ASSOCIATE request arrived on terminates. + // ideally we should be able to shutdown the UDP association + // when the TCP connection is closed, but we don't have a way to do that + // as there is no close() method on UdpSocket. + trace!("UDP relay to {} closed, closing socket", self.remote); + } +} + +impl Sink for Socks5Datagram { + type Error = std::io::Error; + + fn poll_ready( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + let pin = self.get_mut(); + pin.inner.poll_ready_unpin(cx) + } + + fn start_send(self: Pin<&mut Self>, item: UdpPacket) -> Result<(), Self::Error> { + let remote = self.remote; + trace!( + "sending UDP packet to {}, item dst: {}", + remote, + item.dst_addr + ); + let pin = self.get_mut(); + pin.inner + .start_send_unpin(((item.data.into(), item.dst_addr), remote)) + } + + fn poll_flush( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + let pin = self.get_mut(); + pin.inner.poll_flush_unpin(cx) + } + + fn poll_close( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + let pin = self.get_mut(); + pin.inner.poll_close_unpin(cx) + } +} + +impl Stream for Socks5Datagram { + type Item = UdpPacket; + + fn poll_next( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + let pin = self.get_mut(); + pin.inner.poll_next_unpin(cx).map(|opt| { + opt.map(|res| match res { + Ok(((src, data), dst)) => { + trace!("received UDP packet from {} to {}", src, dst,); + UdpPacket { + src_addr: src, + dst_addr: SocksAddr::Ip(dst), + data: data.into(), + } + } + Err(_) => { + error!("failed to decode UDP packet from remote"); + UdpPacket { + src_addr: SocksAddr::any_ipv4(), + dst_addr: SocksAddr::any_ipv4(), + data: Vec::new(), + } + } + }) + }) + } +} diff --git a/clash_lib/src/proxy/socks/outbound/mod.rs b/clash_lib/src/proxy/socks/outbound/mod.rs new file mode 100644 index 000000000..61171ac53 --- /dev/null +++ b/clash_lib/src/proxy/socks/outbound/mod.rs @@ -0,0 +1,358 @@ +mod datagram; + +use crate::{ + app::{ + dispatcher::{ + BoxedChainedDatagram, BoxedChainedStream, ChainedDatagram, + ChainedDatagramWrapper, ChainedStream, ChainedStreamWrapper, + }, + dns::ThreadSafeDNSResolver, + }, + common::errors::new_io_error, + proxy::{ + transport::{self, TLSOptions}, + utils::{new_tcp_stream, new_udp_socket, RemoteConnector}, + AnyOutboundHandler, AnyStream, CommonOption, ConnectorType, OutboundHandler, + OutboundType, + }, + session::Session, +}; + +use async_trait::async_trait; +use datagram::Socks5Datagram; +use std::sync::Arc; +use tracing::trace; + +use super::socks5::{client_handshake, socks_command}; + +#[derive(Default)] +pub struct HandlerOptions { + pub name: String, + pub common_opts: CommonOption, + pub server: String, + pub port: u16, + pub user: Option, + pub password: Option, + pub udp: bool, + pub tls: bool, + pub sni: String, + pub skip_cert_verify: bool, +} + +pub struct Handler { + opts: HandlerOptions, +} + +impl Handler { + #[allow(clippy::new_ret_no_self)] + pub fn new(opts: HandlerOptions) -> AnyOutboundHandler { + Arc::new(Self { opts }) + } + + async fn inner_connect_stream( + &self, + s: AnyStream, + sess: &Session, + ) -> std::io::Result { + let mut s = if self.opts.tls { + trace!( + "TLS config - enabled: {}, skip_cert_verify: {}, sni: {}", + self.opts.tls, + self.opts.skip_cert_verify, + self.opts.sni + ); + let tls_opt = TLSOptions { + skip_cert_verify: self.opts.skip_cert_verify, + sni: self.opts.sni.clone(), + alpn: None, + }; + + transport::tls::wrap_stream(s, tls_opt, None).await? + } else { + s + }; + + client_handshake( + &mut s, + &sess.destination, + socks_command::CONNECT, + self.opts.user.clone(), + self.opts.password.clone(), + ) + .await?; + + Ok(s) + } + + async fn inner_connect_datagram( + &self, + s: AnyStream, + sess: &Session, + resolver: ThreadSafeDNSResolver, + ) -> std::io::Result { + let mut s = if self.opts.tls { + let tls_opt = TLSOptions { + skip_cert_verify: self.opts.skip_cert_verify, + sni: self.opts.sni.clone(), + alpn: None, + }; + + transport::tls::wrap_stream(s, tls_opt, None).await? + } else { + s + }; + + let bind_addr = client_handshake( + &mut s, + &sess.destination, + socks_command::UDP_ASSOCIATE, + self.opts.user.clone(), + self.opts.password.clone(), + ) + .await?; + + let bind_ip = bind_addr + .ip() + .ok_or(new_io_error("missing IP in bind address"))?; + let bind_ip = if bind_ip.is_unspecified() { + trace!("bind address is unspecified, resolving server address"); + let remote_addr = resolver + .resolve(&self.opts.server, false) + .await + .map_err(|x| new_io_error(x.to_string().as_str()))?; + remote_addr.ok_or(new_io_error( + "no bind addr returned from server and failed to resolve server \ + address", + ))? + } else { + trace!("using server returned bind addr {}", bind_ip); + bind_ip + }; + let bind_port = bind_addr.port(); + trace!("bind address resolved to {}:{}", bind_ip, bind_port); + + let udp_socket = new_udp_socket( + None, + self.opts.common_opts.iface.as_ref().or(sess.iface.as_ref()), + #[cfg(any(target_os = "linux", target_os = "android"))] + None, + ) + .await?; + + Ok(Socks5Datagram::new( + s, + (bind_ip, bind_port).into(), + udp_socket, + )) + } +} + +#[async_trait] +impl OutboundHandler for Handler { + fn name(&self) -> &str { + &self.opts.name + } + + fn proto(&self) -> OutboundType { + OutboundType::Socks5 + } + + async fn support_udp(&self) -> bool { + self.opts.udp + } + + async fn connect_stream( + &self, + sess: &Session, + resolver: ThreadSafeDNSResolver, + ) -> std::io::Result { + let s = new_tcp_stream( + resolver, + self.opts.server.as_str(), + self.opts.port, + self.opts.common_opts.iface.as_ref().or(sess.iface.as_ref()), + #[cfg(any(target_os = "linux", target_os = "android"))] + None, + ) + .await?; + + let s = self.inner_connect_stream(s, sess).await?; + + let s = ChainedStreamWrapper::new(s); + s.append_to_chain(self.name()).await; + Ok(Box::new(s)) + } + + // it;s up to the server to allow full cone UDP + // https://github.com/wzshiming/socks5/blob/0e66f80351778057703bd652e8b177fabe443f34/server.go#L368 + async fn connect_datagram( + &self, + sess: &Session, + resolver: ThreadSafeDNSResolver, + ) -> std::io::Result { + let s = new_tcp_stream( + resolver.clone(), + self.opts.server.as_str(), + self.opts.port, + self.opts.common_opts.iface.as_ref().or(sess.iface.as_ref()), + #[cfg(any(target_os = "linux", target_os = "android"))] + None, + ) + .await?; + + let d = self.inner_connect_datagram(s, sess, resolver).await?; + + let d = ChainedDatagramWrapper::new(d); + d.append_to_chain(self.name()).await; + + Ok(Box::new(d)) + } + + async fn support_connector(&self) -> ConnectorType { + ConnectorType::All + } + + async fn connect_stream_with_connector( + &self, + sess: &Session, + resolver: ThreadSafeDNSResolver, + connector: &dyn RemoteConnector, + ) -> std::io::Result { + let s = connector + .connect_stream( + resolver, + self.opts.server.as_str(), + self.opts.port, + self.opts.common_opts.iface.as_ref().or(sess.iface.as_ref()), + #[cfg(any(target_os = "linux", target_os = "android"))] + None, + ) + .await?; + + let s = self.inner_connect_stream(s, sess).await?; + + let s = ChainedStreamWrapper::new(s); + s.append_to_chain(self.name()).await; + Ok(Box::new(s)) + } + + async fn connect_datagram_with_connector( + &self, + sess: &Session, + resolver: ThreadSafeDNSResolver, + connector: &dyn RemoteConnector, + ) -> std::io::Result { + let s = connector + .connect_stream( + resolver.clone(), + self.opts.server.as_str(), + self.opts.port, + self.opts.common_opts.iface.as_ref().or(sess.iface.as_ref()), + #[cfg(any(target_os = "linux", target_os = "android"))] + None, + ) + .await?; + + let d = self.inner_connect_datagram(s, sess, resolver).await?; + + let d = ChainedDatagramWrapper::new(d); + d.append_to_chain(self.name()).await; + Ok(Box::new(d)) + } +} + +#[cfg(all(test, not(ci)))] +mod tests { + + use crate::proxy::{ + socks::{Handler, HandlerOptions}, + utils::test_utils::{ + consts::{IMAGE_SOCKS5, LOCAL_ADDR}, + docker_runner::{DockerTestRunner, DockerTestRunnerBuilder}, + run_test_suites_and_cleanup, Suite, + }, + }; + + const USER: &str = "user"; + const PASSWORD: &str = "password"; + + async fn get_socks5_runner( + port: u16, + username: Option, + password: Option, + ) -> anyhow::Result { + let host = format!("0.0.0.0:{}", port); + let username = username.unwrap_or_default(); + let password = password.unwrap_or_default(); + let cmd = if username != "" && password != "" { + vec![ + "-a", + &host, + "-u", + username.as_str(), + "-p", + password.as_str(), + ] + } else { + vec!["-a", &host] + }; + DockerTestRunnerBuilder::new() + .image(IMAGE_SOCKS5) + .cmd(&cmd) + .build() + .await + } + + #[tokio::test] + #[serial_test::serial] + async fn test_socks5_no_auth() -> anyhow::Result<()> { + let _ = tracing_subscriber::fmt().try_init(); + let opts = HandlerOptions { + name: "test-socks5-no-auth".to_owned(), + common_opts: Default::default(), + server: LOCAL_ADDR.to_owned(), + port: 10002, + user: None, + password: None, + udp: true, + ..Default::default() + }; + let port = opts.port; + let handler = Handler::new(opts); + run_test_suites_and_cleanup( + handler, + get_socks5_runner(port, None, None).await?, + Suite::all(), + ) + .await + } + + #[tokio::test] + #[serial_test::serial] + async fn test_socks5_auth() -> anyhow::Result<()> { + let _ = tracing_subscriber::fmt().try_init(); + let opts = HandlerOptions { + name: "test-socks5-no-auth".to_owned(), + common_opts: Default::default(), + server: LOCAL_ADDR.to_owned(), + port: 10002, + user: Some(USER.to_owned()), + password: Some(PASSWORD.to_owned()), + udp: true, + ..Default::default() + }; + let port = opts.port; + let handler = Handler::new(opts); + run_test_suites_and_cleanup( + handler, + get_socks5_runner( + port, + Some(USER.to_owned()), + Some(PASSWORD.to_owned()), + ) + .await?, + Suite::all(), + ) + .await + } +} diff --git a/clash_lib/src/proxy/socks/socks5.rs b/clash_lib/src/proxy/socks/socks5.rs new file mode 100644 index 000000000..e27bf9054 --- /dev/null +++ b/clash_lib/src/proxy/socks/socks5.rs @@ -0,0 +1,131 @@ +use bytes::{BufMut, BytesMut}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::{common::errors::new_io_error, proxy::AnyStream, session::SocksAddr}; + +pub const SOCKS5_VERSION: u8 = 0x05; + +const MAX_ADDR_LEN: usize = 1 + 1 + 255 + 2; +const MAX_AUTH_LEN: usize = 255; + +pub(crate) mod auth_methods { + pub const NO_AUTH: u8 = 0x00; + pub const USER_PASS: u8 = 0x02; + pub const NO_METHODS: u8 = 0xff; +} + +pub(crate) mod socks_command { + pub const CONNECT: u8 = 0x01; + // pub const BIND: u8 = 0x02; + pub const UDP_ASSOCIATE: u8 = 0x3; +} + +pub(crate) mod response_code { + pub const SUCCEEDED: u8 = 0x00; + pub const FAILURE: u8 = 0x01; + // pub const RULE_FAILURE: u8 = 0x02; + // pub const NETWORK_UNREACHABLE: u8 = 0x03; + // pub const HOST_UNREACHABLE: u8 = 0x04; + // pub const CONNECTION_REFUSED: u8 = 0x05; + // pub const TTL_EXPIRED: u8 = 0x06; + pub const COMMAND_NOT_SUPPORTED: u8 = 0x07; + // pub const ADDR_TYPE_NOT_SUPPORTED: u8 = 0x08; +} + +const ERROR_CODE_LOOKUP: &[&str] = &[ + "succeeded", + "general SOCKS server failure", + "connection not allowed by ruleset", + "network unreachable", + "host unreachable", + "connection refused", + "TTL expired", + "command not supported", + "address type not supported", +]; + +pub(crate) async fn client_handshake( + s: &mut AnyStream, + addr: &SocksAddr, + command: u8, + username: Option, + password: Option, +) -> std::io::Result { + let mut buf = BytesMut::with_capacity(MAX_AUTH_LEN); + buf.put_u8(SOCKS5_VERSION); + + if username.is_some() && password.is_some() { + buf.put_u8(1); + buf.put_u8(auth_methods::USER_PASS); + } else { + buf.put_u8(1); + buf.put_u8(auth_methods::NO_AUTH); + } + s.write_all(&buf).await?; + + s.read_exact(&mut buf[..2]).await?; + if buf[0] != SOCKS5_VERSION { + return Err(new_io_error("unsupported SOCKS version")); + } + + let method = buf[1]; + if method == auth_methods::USER_PASS { + let username = username + .as_ref() + .ok_or_else(|| new_io_error("missing username"))?; + let password = password + .as_ref() + .ok_or_else(|| new_io_error("missing password"))?; + + let mut buf = BytesMut::with_capacity(MAX_AUTH_LEN); + buf.put_u8(1); + buf.put_u8(username.len() as u8); + buf.put_slice(username.as_bytes()); + buf.put_u8(password.len() as u8); + buf.put_slice(password.as_bytes()); + s.write_all(&buf).await?; + + s.read_exact(&mut buf[..2]).await?; + + if buf[1] != response_code::SUCCEEDED { + return Err(new_io_error("SOCKS5 authentication failed")); + } + } else if method != auth_methods::NO_AUTH { + return Err(new_io_error("unsupported SOCKS5 authentication method")); + } + + let mut buf = BytesMut::with_capacity(MAX_ADDR_LEN); + buf.put_u8(SOCKS5_VERSION); + buf.put_u8(command); + buf.put_u8(0x00); + if command == socks_command::UDP_ASSOCIATE { + let addr = SocksAddr::any_ipv4(); + addr.write_buf(&mut buf); + } else { + addr.write_buf(&mut buf); + } + s.write_all(&buf).await?; + + buf.resize(3, 0); + s.read_exact(&mut buf).await?; + + if buf[0] != SOCKS5_VERSION { + return Err(new_io_error("unsupported SOCKS version")); + } + + if buf[1] != response_code::SUCCEEDED { + return Err(new_io_error( + format!( + "SOCKS5 request failed with {}", + if buf[1] < ERROR_CODE_LOOKUP.len() as u8 { + ERROR_CODE_LOOKUP[buf[1] as usize] + } else { + "unknown error" + } + ) + .as_str(), + )); + } + + SocksAddr::read_from(s).await +} diff --git a/clash_lib/src/proxy/trojan/mod.rs b/clash_lib/src/proxy/trojan/mod.rs index 5714e7cb1..ca7554cd4 100644 --- a/clash_lib/src/proxy/trojan/mod.rs +++ b/clash_lib/src/proxy/trojan/mod.rs @@ -38,7 +38,7 @@ pub enum Transport { Grpc(GrpcOption), } -pub struct Opts { +pub struct HandlerOptions { pub name: String, pub common_opts: CommonOption, pub server: String, @@ -52,12 +52,12 @@ pub struct Opts { } pub struct Handler { - opts: Opts, + opts: HandlerOptions, } impl Handler { #[allow(clippy::new_ret_no_self)] - pub fn new(opts: Opts) -> AnyOutboundHandler { + pub fn new(opts: HandlerOptions) -> AnyOutboundHandler { Arc::new(Self { opts }) } @@ -301,7 +301,7 @@ mod tests { let span = tracing::info_span!("test_trojan_ws"); let _enter = span.enter(); - let opts = Opts { + let opts = HandlerOptions { name: "test-trojan-ws".to_owned(), common_opts: Default::default(), server: "127.0.0.1".to_owned(), @@ -347,7 +347,7 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_trojan_grpc() -> anyhow::Result<()> { - let opts = Opts { + let opts = HandlerOptions { name: "test-trojan-grpc".to_owned(), common_opts: Default::default(), server: "127.0.0.1".to_owned(), diff --git a/clash_lib/src/proxy/utils/test_utils/consts.rs b/clash_lib/src/proxy/utils/test_utils/consts.rs index d2e6ce587..9a5fd3da1 100644 --- a/clash_lib/src/proxy/utils/test_utils/consts.rs +++ b/clash_lib/src/proxy/utils/test_utils/consts.rs @@ -7,3 +7,4 @@ pub const IMAGE_OBFS: &str = "liaohuqiu/simple-obfs:latest"; pub const IMAGE_TROJAN_GO: &str = "p4gefau1t/trojan-go:latest"; pub const IMAGE_VMESS: &str = "v2fly/v2fly-core:v4.45.2"; pub const IMAGE_XRAY: &str = "teddysun/xray:latest"; +pub const IMAGE_SOCKS5: &str = "ghcr.io/wzshiming/socks5/socks5:v0.4.3"; diff --git a/clash_lib/src/proxy/wg/mod.rs b/clash_lib/src/proxy/wg/mod.rs index ff3bda584..633f649e3 100644 --- a/clash_lib/src/proxy/wg/mod.rs +++ b/clash_lib/src/proxy/wg/mod.rs @@ -36,7 +36,7 @@ mod ports; mod stack; mod wireguard; -pub struct HandlerOpts { +pub struct HandlerOptions { pub name: String, pub server: String, pub port: u16, @@ -62,13 +62,13 @@ struct Inner { } pub struct Handler { - opts: HandlerOpts, + opts: HandlerOptions, inner: OnceCell, } impl Handler { #[allow(clippy::new_ret_no_self)] - pub fn new(opts: HandlerOpts) -> AnyOutboundHandler { + pub fn new(opts: HandlerOptions) -> AnyOutboundHandler { Arc::new(Self { opts, inner: OnceCell::new(), @@ -340,7 +340,7 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_wg() -> anyhow::Result<()> { - let opts = HandlerOpts { + let opts = HandlerOptions { name: "wg".to_owned(), server: "127.0.0.1".to_owned(), port: 10002, diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 05bf2cc69..fee5b07ec 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,6 +1,25 @@ version: "3.9" services: + socks5-auth: + image: ghcr.io/wzshiming/socks5/socks5:v0.4.3 + network_mode: "host" + command: + - "-u" + - "user" + - "-p" + - "password" + - "-a" + - "0.0.0.0:10801" + restart: unless-stopped + + socks5-noauth: + image: ghcr.io/wzshiming/socks5/socks5:v0.4.3 + network_mode: "host" + command: + - "-a" + - "0.0.0.0:10800" + restart: unless-stopped shadowsocks: build: ./ss network_mode: "host" diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index d794b730f..3e3926422 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -24,4 +24,18 @@ http { grpc_pass grpc://127.0.0.1:9444; } } +} + +stream { + + server { + listen 10802 ssl; + + ssl_certificate /etc/v2ray/v2ray.crt; + ssl_certificate_key /etc/v2ray/v2ray.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + + proxy_pass 127.0.0.1:10801; + } } \ No newline at end of file