rcc-bot/src/viaversion.rs
2026-02-07 19:58:40 +02:00

466 lines
16 KiB
Rust

use std::{io::Cursor, net::SocketAddr, path::Path, process::Stdio};
use anyhow::{Context, Result};
use azalea::{
app::{App, Plugin, Startup, prelude::*},
auth::sessionserver::{
ClientSessionServerError::{ForbiddenOperation, InvalidSession},
join_with_server_id_hash,
},
bevy_tasks::{IoTaskPool, Task, futures_lite::future},
buf::AzBuf,
ecs::prelude::*,
join::StartJoinServerEvent,
packet::login::{ReceiveCustomQueryEvent, SendLoginPacketEvent},
prelude::*,
protocol::{
address::{ResolvedAddr, ServerAddr},
packets::login::ServerboundCustomQueryAnswer,
},
swarm::Swarm,
};
use futures_util::StreamExt;
use kdam::{BarExt, tqdm};
use lazy_regex::{regex_captures, regex_replace_all};
use reqwest::IntoUrl;
use semver::Version;
use tokio::{
fs::File,
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::TcpListener,
process::Command,
};
use tracing::{error, trace, warn};
const JAVA_DOWNLOAD_URL: &str = "https://adoptium.net/installation";
const VIA_OAUTH_VERSION: Version = Version::new(1, 0, 2);
// https://github.com/ViaVersion/ViaProxy/releases
const VIA_PROXY_VERSION: Version = Version::new(3, 4, 8);
#[derive(Clone, Resource)]
pub struct ViaVersionPlugin {
bind_addr: SocketAddr,
mc_version: String,
proxy: Option<String>,
}
impl Plugin for ViaVersionPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(self.clone())
.add_systems(Startup, Self::handle_change_address)
.add_systems(
Update,
(
Self::handle_oauth.before(azalea::login::reply_to_custom_queries),
Self::poll_all_oam_join_tasks,
Self::warn_about_proxy
.after(azalea::auto_reconnect::rejoin_after_delay)
.before(azalea::join::handle_start_join_server_event),
),
);
}
}
impl ViaVersionPlugin {
/// Download and start a ViaProxy instance.
///
/// # Panics
///
/// Will panic if Java fails to parse, files fail to download, or ViaProxy
/// fails to start.
pub async fn start(mc_version: impl ToString) -> Self {
let bind_addr = try_find_free_addr().await.expect("Failed to bind");
let mc_version = mc_version.to_string();
let plugin = Self {
bind_addr,
mc_version,
proxy: None,
};
plugin.start_with_self().await
}
/// Same as [`Self::start`], but allows you to pass any proxy that ViaProxy
/// supports (SOCKS4, SOCKS5, HTTP, HTTPS). Supported formats:
/// - type://address:port
/// - type://username:password@address:port
///
/// This is necessary if you want to use Azalea with a proxy and ViaVersion
/// at the same time. This is incompatible with `JoinOpts::proxy`.
///
/// ```no_run
/// # use azalea::{prelude::*, protocol::connect::Proxy};
/// # use azalea_viaversion::ViaVersionPlugin;
/// #[tokio::main]
/// async fn main() {
/// let account = Account::offline("bot");
///
/// ClientBuilder::new()
/// .set_handler(handle)
/// .add_plugins(
/// ViaVersionPlugin::start_with_proxy("1.21.5", "socks5://10.124.1.186:1080").await,
/// )
/// .start(account, "6.tcp.ngrok.io:14910")
/// .await;
/// }
/// # async fn handle(mut bot: Client, event: Event, state: azalea::NoState) { }
/// ```
pub async fn start_with_proxy(mc_version: impl ToString, proxy: &str) -> Self {
let bind_addr = try_find_free_addr().await.expect("Failed to bind");
let mc_version = mc_version.to_string();
let plugin = Self {
bind_addr,
mc_version,
proxy: Some(proxy.to_string()),
};
plugin.start_with_self().await
}
async fn start_with_self(self) -> Self {
let Some(java_version) = try_find_java_version().await.expect("Failed to parse") else {
panic!(
"Java installation not found! Please download Java from {JAVA_DOWNLOAD_URL} or use your system's package manager."
);
};
trace!("via version starting");
let mc_path = minecraft_folder_path::minecraft_dir().expect("Unsupported Platform");
#[rustfmt::skip]
let via_proxy_ext = if java_version.major < 17 { "+java8.jar" } else { ".jar" };
let via_proxy_name = format!("ViaProxy-{VIA_PROXY_VERSION}{via_proxy_ext}");
let via_proxy_path = mc_path.join("azalea-viaversion");
let via_proxy_url = format!(
"https://github.com/ViaVersion/ViaProxy/releases/download/v{VIA_PROXY_VERSION}/{via_proxy_name}"
);
try_download_file(via_proxy_url, &via_proxy_path, &via_proxy_name)
.await
.expect("Failed to download ViaProxy");
let via_oauth_name = format!("ViaProxyOpenAuthMod-{VIA_OAUTH_VERSION}.jar");
let via_oauth_path = via_proxy_path.join("plugins");
let via_oauth_url = format!(
"https://github.com/ViaVersionAddons/ViaProxyOpenAuthMod/releases/download/v{VIA_OAUTH_VERSION}/{via_oauth_name}"
);
try_download_file(via_oauth_url, &via_oauth_path, &via_oauth_name)
.await
.expect("Failed to download ViaProxyOpenAuthMod");
let mut command = Command::new("java");
command
/* Java Args */
.args(["-jar", &via_proxy_name])
/* ViaProxy Args */
.arg("cli")
.args(["--auth-method", "OPENAUTHMOD"])
.args(["--bind-address", &self.bind_addr.to_string()])
.args(["--target-address", "127.0.0.1:0"])
.args(["--target-version", &self.mc_version])
.args(["--wildcard-domain-handling", "INTERNAL"])
.args(["--suppress-client-protocol-errors", "true"]);
if let Some(proxy) = &self.proxy {
trace!("Starting ViaProxy with proxy: {proxy}");
command.args(["--backend-proxy-url", proxy]);
}
let mut child = command
.current_dir(via_proxy_path)
.stdout(Stdio::piped())
//.stderr(Stdio::piped())
.spawn()
.expect("Failed to spawn");
let (tx, mut rx) = tokio::sync::watch::channel(());
tokio::spawn(async move {
let mut stdout = child.stdout.as_mut().expect("Failed to get stdout");
let mut reader = BufReader::new(&mut stdout);
let mut line = String::new();
loop {
line.clear();
reader.read_line(&mut line).await.expect("Failed to read");
let line = line.trim();
// strip ansi escape codes
let line = regex_replace_all!(r"(\x1b\[[0-9;]*m)", line, |_, _| "");
if line.contains("/WARN]")
&& !line.contains("Missing 1.20.2 blockstate")
&& !line.contains("Missing 1.20.2 item")
{
warn!("{line}");
} else {
trace!("{line}");
}
if line.contains("Finished mapping loading") {
let _ = tx.send(());
}
}
});
/* Wait until ViaProxy is ready */
let _ = rx.changed().await;
self
}
#[allow(clippy::needless_pass_by_value)]
pub fn handle_change_address(plugin: Res<Self>, swarm: Res<Swarm>) {
let ResolvedAddr { server, .. } = swarm.address.read().clone();
let ServerAddr { host, port } = server;
// sadly, the first part of the resolved address is unused as viaproxy will
// resolve it on its own more info: https://github.com/ViaVersion/ViaProxy/issues/338
let data_after_null_byte = host.split_once('\x07').map(|(_, data)| data);
let mut connection_host = format!(
"localhost\x07{host}:{port}\x07{version}",
version = plugin.mc_version
);
if let Some(data) = data_after_null_byte {
connection_host.push('\0');
connection_host.push_str(data);
}
*swarm.address.write() = ResolvedAddr {
server: ServerAddr {
port,
host: connection_host,
},
socket: plugin.bind_addr,
};
/* Must wait to be written until after reading above */
}
pub fn handle_oauth(
mut commands: Commands,
mut events: MessageMutator<ReceiveCustomQueryEvent>,
mut query: Query<&Account>,
) {
for event in events.read() {
if event.packet.identifier.to_string().as_str() != "oam:join" {
continue;
}
let mut buf = Cursor::new(&*event.packet.data);
let Ok(hash) = String::azalea_read(&mut buf) else {
error!("Failed to read server id hash from oam:join packet");
continue;
};
let Ok(account) = query.get_mut(event.entity) else {
continue;
};
// this makes it so azalea doesn't reply to the query so we can handle it
// ourselves
event.disabled = true;
let Some(token) = account.access_token() else {
warn!("The server tried to make us authenticate, but our account is offline-mode");
commands.trigger(SendLoginPacketEvent::new(
event.entity,
build_custom_query_answer(event.packet.transaction_id, true),
));
continue;
};
let client = reqwest::Client::new();
let uuid = account.uuid();
let account = account.clone();
let transaction_id = event.packet.transaction_id;
let task_pool = IoTaskPool::get();
let task = task_pool.spawn(async move {
// joining servers uses tokio, but we poll the task with `futures`
let res = async_compat::Compat::new(async {
Some(
match join_with_server_id_hash(&client, &token, &uuid, &hash).await {
Ok(()) => Ok(()), /* Successfully Authenticated */
Err(InvalidSession | ForbiddenOperation) => {
if let Err(error) = account.refresh().await {
error!("Failed to refresh account: {error}");
return None;
}
/* Retry after refreshing */
join_with_server_id_hash(&client, &token, &uuid, &hash).await
}
Err(error) => Err(error),
},
)
})
.await?;
Some(build_custom_query_answer(transaction_id, res.is_ok()))
});
commands
.entity(event.entity)
.insert(OpenAuthModJoinTask(task));
}
}
fn poll_all_oam_join_tasks(
mut commands: Commands,
mut tasks: Query<(Entity, &mut OpenAuthModJoinTask)>,
) {
for (entity, mut task) in tasks.iter_mut() {
let Some(res) = future::block_on(future::poll_once(&mut task.0)) else {
continue;
};
commands.entity(entity).remove::<OpenAuthModJoinTask>();
let Some(packet) = res else {
error!("Failed to do Mojang auth for openauthmod, not sending response");
continue;
};
commands.trigger(SendLoginPacketEvent::new(entity, packet));
// inserting this is necessary to make azalea send the chat signing certs
commands
.entity(entity)
.insert(azalea::login::IsAuthenticated);
}
}
fn warn_about_proxy(mut events: MessageMutator<StartJoinServerEvent>) {
for event in events.read() {
if event.connect_opts.server_proxy.is_some() {
warn!(
"You are using JoinOpts::proxy and ViaVersionPlugin at the same time, which is not a supported configuration. \
Please set your proxy with `ViaVersionPlugin::start_with_proxy` instead."
);
}
}
}
}
#[derive(Component)]
pub struct OpenAuthModJoinTask(Task<Option<ServerboundCustomQueryAnswer>>);
fn build_custom_query_answer(transaction_id: u32, success: bool) -> ServerboundCustomQueryAnswer {
ServerboundCustomQueryAnswer {
transaction_id,
data: Some(vec![u8::from(success)].into()),
}
}
/// Try to find the system's Java version.
///
/// This uses `-version` and `stderr`, because it's backwards compatible.
///
/// # Errors
/// Will return `Err` if `Version::parse` fails.
///
/// # Options
/// Will return `None` if java is not found.
pub async fn try_find_java_version() -> Result<Option<Version>> {
Ok(match Command::new("java").arg("-version").output().await {
Err(_) => None, /* Java not found */
Ok(output) => {
let stderr = String::from_utf8(output.stderr).context("UTF-8")?;
Some(parse_java_version(&stderr)?)
}
})
}
fn parse_java_version(stderr: &str) -> Result<Version> {
// whole, first group, second group
let (_, major, mut minor_patch) =
regex_captures!(r"(\d+)(\.\d+\.\d+)?", stderr).context("Regex")?;
if minor_patch.is_empty() {
minor_patch = ".0.0";
}
let text = format!("{major}{minor_patch}");
Ok(Version::parse(&text)?)
}
/// Try to find a free port and return the socket address
///
/// This uses `TcpListener` to ask the system for a free port.
///
/// # Errors
/// Will return `Err` if `TcpListener::bind` or `TcpListener::local_addr` fails.
pub async fn try_find_free_addr() -> Result<SocketAddr> {
Ok(TcpListener::bind("127.0.0.1:0").await?.local_addr()?)
}
/// Try to download and save a file if it doesn't exist.
///
/// # Errors
/// Will return `Err` if the file fails to download or save.
pub async fn try_download_file<U, P>(url: U, dir: P, file: &str) -> Result<()>
where
U: IntoUrl + Send + Sync,
P: AsRef<Path> + Send + Sync,
{
tokio::fs::create_dir_all(&dir).await?;
let path = dir.as_ref().join(file);
if path.exists() {
return Ok(());
}
let response = reqwest::get(url).await?;
let mut pb = tqdm!(
total = usize::try_from(response.content_length().unwrap_or(0))?,
unit_scale = true,
unit_divisor = 1024,
unit = "B",
force_refresh = true
);
pb.write(format!("Downloading {file}"))?;
let mut file = File::create(path).await?;
let mut stream = response.bytes_stream();
while let Some(item) = stream.next().await {
let chunk = item?;
file.write_all(&chunk).await?;
pb.update(chunk.len())?;
}
pb.refresh()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_openjdk_ea() {
let stderr = "openjdk version \"24-ea\" 2025-03-18
OpenJDK Runtime Environment (build 24-ea+29-3578)
OpenJDK 64-Bit Server VM (build 24-ea+29-3578, mixed mode, sharing)"
.to_string();
let version = parse_java_version(&stderr).unwrap();
assert_eq!(version, Version::new(24, 0, 0));
}
#[test]
fn test_parse_openjdk_8() {
let stderr = "openjdk version \"1.8.0_432\"
OpenJDK Runtime Environment (build 1.8.0_432-b05)
OpenJDK 64-Bit Server VM (build 25.432-b05, mixed mode)"
.to_string();
let version = parse_java_version(&stderr).unwrap();
assert_eq!(version, Version::new(1, 8, 0));
}
#[test]
fn test_parse_openjdk_11() {
let stderr = "openjdk version \"11.0.25\" 2024-10-15
OpenJDK Runtime Environment (build 11.0.25+9)
OpenJDK 64-Bit Server VM (build 11.0.25+9, mixed mode)"
.to_string();
let version = parse_java_version(&stderr).unwrap();
assert_eq!(version, Version::new(11, 0, 25));
}
}