From 353eda21ac8280213edcec3823cc3bd77fe17c44 Mon Sep 17 00:00:00 2001 From: mat Date: Sat, 20 Apr 2024 03:40:59 +0000 Subject: [PATCH] socks5 support (#113) --- Cargo.lock | 22 ++++++ azalea-client/Cargo.toml | 1 + azalea-client/src/client.rs | 11 ++- azalea-client/src/ping.rs | 29 ++++++-- azalea-protocol/Cargo.toml | 2 + azalea-protocol/src/connect.rs | 38 +++++++++- azalea/examples/testbot/main.rs | 6 +- azalea/src/lib.rs | 71 +++++++++++++++--- azalea/src/swarm/mod.rs | 124 ++++++++++++++++++++------------ 9 files changed, 240 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 741cce8e..1c0ea9be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,12 @@ version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +[[package]] +name = "as-any" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a30a44e99a1c83ccb2a6298c563c888952a1c9134953db26876528f84c93a" + [[package]] name = "async-channel" version = "2.2.0" @@ -353,6 +359,7 @@ dependencies = [ "serde", "serde_json", "simdnbt", + "socks5-impl", "thiserror", "tokio", "tracing", @@ -495,6 +502,7 @@ dependencies = [ "serde", "serde_json", "simdnbt", + "socks5-impl", "thiserror", "tokio", "tokio-util", @@ -2533,6 +2541,20 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "socks5-impl" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfc11441196e51be4f48c72b075e7fff394a3c6a43f93420f907a2708079b27" +dependencies = [ + "as-any", + "async-trait", + "byteorder", + "bytes", + "thiserror", + "tokio", +] + [[package]] name = "spin" version = "0.5.2" diff --git a/azalea-client/Cargo.toml b/azalea-client/Cargo.toml index 78411ed9..b79f694a 100644 --- a/azalea-client/Cargo.toml +++ b/azalea-client/Cargo.toml @@ -43,6 +43,7 @@ azalea-entity = { version = "0.9.0", path = "../azalea-entity" } serde_json = "1.0.113" serde = "1.0.196" minecraft_folder_path = "0.1.2" +socks5-impl = "0.5.6" [features] default = ["log"] diff --git a/azalea-client/src/client.rs b/azalea-client/src/client.rs index af535415..8ca0bbef 100644 --- a/azalea-client/src/client.rs +++ b/azalea-client/src/client.rs @@ -34,7 +34,7 @@ use azalea_entity::{ }; use azalea_physics::PhysicsPlugin; use azalea_protocol::{ - connect::{Connection, ConnectionError}, + connect::{Connection, ConnectionError, Proxy}, packets::{ configuration::{ serverbound_client_information_packet::ClientInformation, @@ -183,6 +183,7 @@ impl Client { pub async fn join( account: &Account, address: impl TryInto, + proxy: Option, ) -> Result<(Self, mpsc::UnboundedReceiver), JoinError> { let address: ServerAddress = address.try_into().map_err(|_| JoinError::InvalidAddress)?; let resolved_address = resolver::resolve_address(&address).await?; @@ -200,6 +201,7 @@ impl Client { account, &address, &resolved_address, + proxy, run_schedule_sender, ) .await @@ -212,6 +214,7 @@ impl Client { account: &Account, address: &ServerAddress, resolved_address: &SocketAddr, + proxy: Option, run_schedule_sender: mpsc::UnboundedSender<()>, ) -> Result<(Self, mpsc::UnboundedReceiver), JoinError> { // check if an entity with our uuid already exists in the ecs and if so then @@ -239,7 +242,11 @@ impl Client { entity }; - let conn = Connection::new(resolved_address).await?; + let conn = if let Some(proxy) = proxy { + Connection::new_with_proxy(resolved_address, proxy).await? + } else { + Connection::new(resolved_address).await? + }; let (conn, game_profile) = Self::handshake(ecs_lock.clone(), entity, conn, account, address).await?; diff --git a/azalea-client/src/ping.rs b/azalea-client/src/ping.rs index 9064065c..c74a62be 100755 --- a/azalea-client/src/ping.rs +++ b/azalea-client/src/ping.rs @@ -1,9 +1,12 @@ //! Ping Minecraft servers. use azalea_protocol::{ - connect::{Connection, ConnectionError}, + connect::{Connection, ConnectionError, Proxy}, packets::{ - handshaking::client_intention_packet::ClientIntentionPacket, + handshaking::{ + client_intention_packet::ClientIntentionPacket, ClientboundHandshakePacket, + ServerboundHandshakePacket, + }, status::{ clientbound_status_response_packet::ClientboundStatusResponsePacket, serverbound_status_request_packet::ServerboundStatusRequestPacket, @@ -47,11 +50,29 @@ pub async fn ping_server( address: impl TryInto, ) -> Result { let address: ServerAddress = address.try_into().map_err(|_| PingError::InvalidAddress)?; - let resolved_address = resolver::resolve_address(&address).await?; + let conn = Connection::new(&resolved_address).await?; + ping_server_with_connection(address, conn).await +} - let mut conn = Connection::new(&resolved_address).await?; +/// Ping a Minecraft server through a Socks5 proxy. +pub async fn ping_server_with_proxy( + address: impl TryInto, + proxy: Proxy, +) -> Result { + let address: ServerAddress = address.try_into().map_err(|_| PingError::InvalidAddress)?; + let resolved_address = resolver::resolve_address(&address).await?; + let conn = Connection::new_with_proxy(&resolved_address, proxy).await?; + ping_server_with_connection(address, conn).await +} +/// Ping a Minecraft server after we've already created a [`Connection`]. The +/// `Connection` must still be in the handshake state (which is the state it's +/// in immediately after it's created). +pub async fn ping_server_with_connection( + address: ServerAddress, + mut conn: Connection, +) -> Result { // send the client intention packet and switch to the status state conn.write( ClientIntentionPacket { diff --git a/azalea-protocol/Cargo.toml b/azalea-protocol/Cargo.toml index 93908770..609d2019 100644 --- a/azalea-protocol/Cargo.toml +++ b/azalea-protocol/Cargo.toml @@ -48,6 +48,8 @@ trust-dns-resolver = { version = "^0.23.2", default-features = false, features = uuid = "1.7.0" log = "0.4.20" +socks5-impl = "0.5.6" + [features] connecting = [] default = ["packets"] diff --git a/azalea-protocol/src/connect.rs b/azalea-protocol/src/connect.rs index 86b92693..cc1b71c1 100755 --- a/azalea-protocol/src/connect.rs +++ b/azalea-protocol/src/connect.rs @@ -20,7 +20,7 @@ use std::io::Cursor; use std::marker::PhantomData; use std::net::SocketAddr; use thiserror::Error; -use tokio::io::AsyncWriteExt; +use tokio::io::{AsyncWriteExt, BufStream}; use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf, ReuniteError}; use tokio::net::TcpStream; use tracing::{error, info}; @@ -257,6 +257,20 @@ pub enum ConnectionError { Io(#[from] std::io::Error), } +use socks5_impl::protocol::UserKey; + +#[derive(Debug, Clone)] +pub struct Proxy { + pub addr: SocketAddr, + pub auth: Option, +} + +impl Proxy { + pub fn new(addr: SocketAddr, auth: Option) -> Self { + Self { addr, auth } + } +} + impl Connection { /// Create a new connection to the given address. pub async fn new(address: &SocketAddr) -> Result { @@ -265,6 +279,28 @@ impl Connection { // enable tcp_nodelay stream.set_nodelay(true)?; + Self::new_from_stream(stream).await + } + + /// Create a new connection to the given address and Socks5 proxy. If you're + /// not using a proxy, use [`Self::new`] instead. + pub async fn new_with_proxy( + address: &SocketAddr, + proxy: Proxy, + ) -> Result { + let proxy_stream = TcpStream::connect(proxy.addr).await?; + let mut stream = BufStream::new(proxy_stream); + + let _ = socks5_impl::client::connect(&mut stream, address, proxy.auth) + .await + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + Self::new_from_stream(stream.into_inner()).await + } + + /// Create a new connection from an existing stream. Useful if you want to + /// set custom options on the stream. Otherwise, just use [`Self::new`]. + pub async fn new_from_stream(stream: TcpStream) -> Result { let (read_stream, write_stream) = stream.into_split(); Ok(Connection { diff --git a/azalea/examples/testbot/main.rs b/azalea/examples/testbot/main.rs index 86395b7e..6795e6cf 100644 --- a/azalea/examples/testbot/main.rs +++ b/azalea/examples/testbot/main.rs @@ -181,10 +181,12 @@ async fn swarm_handle( _state: SwarmState, ) -> anyhow::Result<()> { match &event { - SwarmEvent::Disconnect(account) => { + SwarmEvent::Disconnect(account, join_opts) => { println!("bot got kicked! {}", account.username); tokio::time::sleep(Duration::from_secs(5)).await; - swarm.add_and_retry_forever(account, State::default()).await; + swarm + .add_and_retry_forever_with_opts(account, State::default(), join_opts) + .await; } SwarmEvent::Chat(chat) => { if chat.message().to_string() == "The particle was not visible for anybody" { diff --git a/azalea/src/lib.rs b/azalea/src/lib.rs index 6e18ff7d..7d8b424c 100644 --- a/azalea/src/lib.rs +++ b/azalea/src/lib.rs @@ -38,6 +38,7 @@ pub use azalea_world as world; pub use bot::*; use ecs::component::Component; use futures::{future::BoxFuture, Future}; +use protocol::connect::Proxy; use protocol::{resolver::ResolverError, ServerAddress}; use swarm::SwarmBuilder; use thiserror::Error; @@ -185,30 +186,26 @@ where account: Account, address: impl TryInto, ) -> Result { - self.swarm.accounts = vec![account]; + self.swarm.accounts = vec![(account, JoinOpts::default())]; if self.swarm.states.is_empty() { self.swarm.states = vec![S::default()]; } self.swarm.start(address).await } - /// Do the same as [`Self::start`], but allow passing in a custom resolved - /// address. This is useful if the address you're connecting to doesn't - /// resolve to anything, like if the server uses the address field to pass - /// custom data (like Bungeecord or Forge). - pub async fn start_with_custom_resolved_address( + /// Do the same as [`Self::start`], but allow passing in custom join + /// options. + pub async fn start_with_opts( mut self, account: Account, address: impl TryInto, - resolved_address: SocketAddr, + opts: JoinOpts, ) -> Result { - self.swarm.accounts = vec![account]; + self.swarm.accounts = vec![(account, opts.clone())]; if self.swarm.states.is_empty() { self.swarm.states = vec![S::default()]; } - self.swarm - .start_with_custom_resolved_address(address, resolved_address) - .await + self.swarm.start_with_default_opts(address, opts).await } } impl Default for ClientBuilder { @@ -224,3 +221,55 @@ impl Default for ClientBuilder { /// [`SwarmBuilder`]: swarm::SwarmBuilder #[derive(Component, Clone, Default)] pub struct NoState; + +/// Optional settings when adding an account to a swarm or client. +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct JoinOpts { + /// The Socks5 proxy that this bot will use. + pub proxy: Option, + /// Override the server address that this specific bot will send in the + /// handshake packet. + pub custom_address: Option, + /// Override the socket address that this specific bot will use to connect + /// to the server. + pub custom_resolved_address: Option, +} + +impl JoinOpts { + pub fn new() -> Self { + Self::default() + } + + pub fn update(&mut self, other: &Self) { + if let Some(proxy) = other.proxy.clone() { + self.proxy = Some(proxy); + } + if let Some(custom_address) = other.custom_address.clone() { + self.custom_address = Some(custom_address); + } + if let Some(custom_resolved_address) = other.custom_resolved_address { + self.custom_resolved_address = Some(custom_resolved_address); + } + } + + /// Set the proxy that this bot will use. + #[must_use] + pub fn proxy(mut self, proxy: Proxy) -> Self { + self.proxy = Some(proxy); + self + } + /// Set the custom address that this bot will send in the handshake packet. + #[must_use] + pub fn custom_address(mut self, custom_address: ServerAddress) -> Self { + self.custom_address = Some(custom_address); + self + } + /// Set the custom resolved address that this bot will use to connect to the + /// server. + #[must_use] + pub fn custom_resolved_address(mut self, custom_resolved_address: SocketAddr) -> Self { + self.custom_resolved_address = Some(custom_resolved_address); + self + } +} diff --git a/azalea/src/swarm/mod.rs b/azalea/src/swarm/mod.rs index 2be56567..a53e6fe8 100644 --- a/azalea/src/swarm/mod.rs +++ b/azalea/src/swarm/mod.rs @@ -17,7 +17,7 @@ use std::{collections::HashMap, future::Future, net::SocketAddr, sync::Arc, time use tokio::sync::mpsc; use tracing::error; -use crate::{BoxHandleFn, DefaultBotPlugins, HandleFn, NoState, StartError}; +use crate::{BoxHandleFn, DefaultBotPlugins, HandleFn, JoinOpts, NoState, StartError}; /// A swarm is a way to conveniently control many bots at once, while also /// being able to control bots at an individual level when desired. @@ -51,8 +51,8 @@ where SS: Default + Send + Sync + Clone + Resource + 'static, { pub(crate) app: App, - /// The accounts that are going to join the server. - pub(crate) accounts: Vec, + /// The accounts and proxies that are going to join the server. + pub(crate) accounts: Vec<(Account, JoinOpts)>, /// The individual bot states. This must be the same length as `accounts`, /// since each bot gets one state. pub(crate) states: Vec, @@ -257,8 +257,20 @@ where /// Add an account with a custom initial state. Use just /// [`Self::add_account`] to use the Default implementation for the state. #[must_use] - pub fn add_account_with_state(mut self, account: Account, state: S) -> Self { - self.accounts.push(account); + pub fn add_account_with_state(self, account: Account, state: S) -> Self { + self.add_account_with_state_and_opts(account, state, JoinOpts::default()) + } + + /// Same as [`Self::add_account_with_state`], but allow passing in custom + /// join options. + #[must_use] + pub fn add_account_with_state_and_opts( + mut self, + account: Account, + state: S, + join_opts: JoinOpts, + ) -> Self { + self.accounts.push((account, join_opts)); self.states.push(state); self } @@ -302,21 +314,16 @@ where Err(_) => return Err(StartError::InvalidAddress), }; - // resolve the address - let resolved_address = resolver::resolve_address(&address).await?; - - self.start_with_custom_resolved_address(address, resolved_address) + self.start_with_default_opts(address, JoinOpts::default()) .await } - /// Do the same as [`Self::start`], but allow passing in a custom resolved - /// address. This is useful if the address you're connecting to doesn't - /// resolve to anything, like if the server uses the address field to pass - /// custom data (like Bungeecord or Forge). - pub async fn start_with_custom_resolved_address( + /// Do the same as [`Self::start`], but allow passing in default join + /// options for the bots. + pub async fn start_with_default_opts( self, address: impl TryInto, - resolved_address: SocketAddr, + default_join_opts: JoinOpts, ) -> Result { assert_eq!( self.accounts.len(), @@ -325,11 +332,17 @@ where ); // convert the TryInto into a ServerAddress - let address: ServerAddress = match address.try_into() { + let address = match address.try_into() { Ok(address) => address, Err(_) => return Err(StartError::InvalidAddress), }; + let address: ServerAddress = default_join_opts.custom_address.clone().unwrap_or(address); + let resolved_address: SocketAddr = match default_join_opts.custom_resolved_address { + Some(resolved_address) => resolved_address, + None => resolver::resolve_address(&address).await?, + }; + let instance_container = Arc::new(RwLock::new(InstanceContainer::default())); // we can't modify the swarm plugins after this @@ -378,24 +391,27 @@ where tokio::spawn(async move { if let Some(join_delay) = join_delay { // if there's a join delay, then join one by one - for (account, state) in accounts.iter().zip(states) { - swarm_clone.add_and_retry_forever(account, state).await; + for ((account, bot_join_opts), state) in accounts.iter().zip(states) { + let mut join_opts = default_join_opts.clone(); + join_opts.update(bot_join_opts); + swarm_clone + .add_and_retry_forever_with_opts(account, state, &join_opts) + .await; tokio::time::sleep(join_delay).await; } } else { // otherwise, join all at once let swarm_borrow = &swarm_clone; - join_all( - accounts - .iter() - .zip(states) - .map(move |(account, state)| async { - swarm_borrow - .clone() - .add_and_retry_forever(account, state) - .await; - }), - ) + join_all(accounts.iter().zip(states).map( + |((account, bot_join_opts), state)| async { + let mut join_opts = default_join_opts.clone(); + join_opts.update(bot_join_opts); + swarm_borrow + .clone() + .add_and_retry_forever_with_opts(account, state, &join_opts) + .await; + }, + )) .await; } @@ -460,9 +476,9 @@ pub enum SwarmEvent { Init, /// A bot got disconnected from the server. /// - /// You can implement an auto-reconnect by calling [`Swarm::add`] - /// with the account from this event. - Disconnect(Box), + /// You can implement an auto-reconnect by calling [`Swarm::add_with_opts`] + /// with the account and options from this event. + Disconnect(Box, JoinOpts), /// At least one bot received a chat message. Chat(ChatPacket), } @@ -544,31 +560,36 @@ impl Swarm { account: &Account, state: S, ) -> Result { - let address = self.address.read().clone(); - let resolved_address = *self.resolved_address.read(); - - self.add_with_custom_address(account, state, address, resolved_address) + self.add_with_opts(account, state, JoinOpts::default()) .await } - /// Add a new account to the swarm, using the given host and socket - /// address. This is useful if you want bots in the same swarm to connect to - /// different addresses. Usually you'll just want [`Self::add`] though. + /// Add a new account to the swarm, using custom options. This is useful if + /// you want bots in the same swarm to connect to different addresses. + /// Usually you'll just want [`Self::add`] though. /// /// # Errors /// /// Returns an `Err` if the bot could not do a handshake successfully. - pub async fn add_with_custom_address( + pub async fn add_with_opts( &mut self, account: &Account, state: S, - address: ServerAddress, - resolved_address: SocketAddr, + opts: JoinOpts, ) -> Result { + let address = opts + .custom_address + .clone() + .unwrap_or_else(|| self.address.read().clone()); + let resolved_address = opts + .custom_resolved_address + .unwrap_or_else(|| *self.resolved_address.read()); + let (bot, mut rx) = Client::start_client( self.ecs_lock.clone(), account, &address, &resolved_address, + opts.proxy.clone(), self.run_schedule_sender.clone(), ) .await?; @@ -597,7 +618,7 @@ impl Swarm { .get_component::() .expect("bot is missing required Account component"); swarm_tx - .send(SwarmEvent::Disconnect(Box::new(account))) + .send(SwarmEvent::Disconnect(Box::new(account), opts)) .unwrap(); }); @@ -613,10 +634,25 @@ impl Swarm { &mut self, account: &Account, state: S, + ) -> Client { + self.add_and_retry_forever_with_opts(account, state, &JoinOpts::default()) + .await + } + + /// Same as [`Self::add_and_retry_forever`], but allow passing custom join + /// options. + pub async fn add_and_retry_forever_with_opts( + &mut self, + account: &Account, + state: S, + opts: &JoinOpts, ) -> Client { let mut disconnects = 0; loop { - match self.add(account, state.clone()).await { + match self + .add_with_opts(account, state.clone(), opts.clone()) + .await + { Ok(bot) => return bot, Err(e) => { disconnects += 1;