diff --git a/Cargo.lock b/Cargo.lock index b94105d..3738494 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4275,7 +4275,9 @@ dependencies = [ name = "mumble-web2-common" version = "0.1.0" dependencies = [ + "color-eyre", "serde", + "tokio", ] [[package]] @@ -4298,7 +4300,6 @@ dependencies = [ "hmac-sha256", "mumble-web2-common", "once_cell", - "rand 0.9.2", "rcgen", "rustls", "salvo", diff --git a/client/Cargo.toml b/client/Cargo.toml index a7e498f..6698c75 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -133,6 +133,7 @@ desktop = [ "cpal", "dasp_ring_buffer", "etcetera", + "mumble-web2-common/networking", ] mobile = [ "tokio", @@ -141,4 +142,5 @@ mobile = [ "opus", "cpal", "dasp_ring_buffer", + "mumble-web2-common/networking", ] diff --git a/client/src/imp/connect.rs b/client/src/imp/connect.rs index 92c268b..b6654b4 100644 --- a/client/src/imp/connect.rs +++ b/client/src/imp/connect.rs @@ -1,5 +1,5 @@ use crate::app::{Command, SharedState}; -use color_eyre::eyre::{bail, Error}; +use color_eyre::eyre::Error; use futures_channel::mpsc::UnboundedReceiver; use mumble_protocol::control::ClientControlCodec; use std::net::ToSocketAddrs; @@ -14,7 +14,7 @@ use tokio_rustls::TlsConnector; use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _}; use tracing::{info, instrument}; -use mumble_web2_common::{ProxyOverrides, ServerStatus}; +use mumble_web2_common::ProxyOverrides; #[derive(Debug)] struct NoCertificateVerification; @@ -108,10 +108,6 @@ pub async fn network_connect( crate::network_loop(username, state, event_rx, outgoing_send, reader).await } -pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result { - bail!("status not supported on desktop yet") -} - #[allow(unused)] pub use tokio::spawn; #[allow(unused)] diff --git a/client/src/imp/desktop.rs b/client/src/imp/desktop.rs index 9a1616e..9da9e62 100644 --- a/client/src/imp/desktop.rs +++ b/client/src/imp/desktop.rs @@ -33,8 +33,11 @@ impl super::PlatformInterface for DesktopPlatform { super::connect::network_connect(address, username, event_rx, overrides, state).await } - async fn get_status(client: &reqwest::Client) -> color_eyre::Result { - super::connect::get_status(client).await + async fn get_status( + _client: &reqwest::Client, + address: &str, + ) -> color_eyre::Result { + mumble_web2_common::ping_server(address, 64738).await } fn init_logging() { diff --git a/client/src/imp/mobile.rs b/client/src/imp/mobile.rs index 66da970..a973920 100644 --- a/client/src/imp/mobile.rs +++ b/client/src/imp/mobile.rs @@ -29,8 +29,11 @@ impl super::PlatformInterface for MobilePlatform { super::connect::network_connect(address, username, event_rx, overrides, state).await } - async fn get_status(client: &reqwest::Client) -> color_eyre::Result { - super::connect::get_status(client).await + async fn get_status( + _client: &reqwest::Client, + address: &str, + ) -> color_eyre::Result { + mumble_web2_common::ping_server(address, 64738).await } fn init_logging() { diff --git a/client/src/imp/mod.rs b/client/src/imp/mod.rs index 0004193..c9bd707 100644 --- a/client/src/imp/mod.rs +++ b/client/src/imp/mod.rs @@ -86,9 +86,14 @@ pub trait PlatformInterface { state: SharedState, ) -> impl Future>; - /// Get server status (user count, version, etc.). + /// Get server status (user count, version, etc.) for the given address. + /// + /// On web, this goes through the proxy's /status endpoint and ignores `address` + /// (the proxy is bound to a specific server). On desktop/mobile, this pings the + /// given address directly via UDP. fn get_status( client: &reqwest::Client, + address: &str, ) -> impl Future>; /// Load the proxy overrides (proxy URL, cert hash, etc.). diff --git a/client/src/imp/stub.rs b/client/src/imp/stub.rs index 340c9be..6744514 100644 --- a/client/src/imp/stub.rs +++ b/client/src/imp/stub.rs @@ -32,6 +32,7 @@ impl super::PlatformInterface for StubPlatform { fn get_status( _client: &reqwest::Client, + _address: &str, ) -> impl Future> { async { panic!("stubbed platform") } } diff --git a/client/src/imp/web.rs b/client/src/imp/web.rs index 959b8af..83080ec 100644 --- a/client/src/imp/web.rs +++ b/client/src/imp/web.rs @@ -117,7 +117,10 @@ impl super::PlatformInterface for WebPlatform { network_connect(address, username, event_rx, overrides, state).await } - async fn get_status(client: &reqwest::Client) -> color_eyre::Result { + async fn get_status( + client: &reqwest::Client, + _address: &str, + ) -> color_eyre::Result { Ok(client .get(absolute_url("status")?) .send() diff --git a/common/Cargo.toml b/common/Cargo.toml index e1c5658..e5571e0 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -3,5 +3,10 @@ name = "mumble-web2-common" version = "0.1.0" edition = "2021" +[features] +networking = ["dep:tokio", "dep:color-eyre"] + [dependencies] serde = { workspace = true } +tokio = { version = "1", features = ["net", "time"], optional = true } +color-eyre = { version = "0.6", optional = true } diff --git a/common/src/lib.rs b/common/src/lib.rs index 4821d9a..a3277a6 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -16,3 +16,62 @@ pub struct ServerStatus { pub max_users: Option, pub bandwidth: Option, } + +/// Mumble UDP ping protocol. +/// +/// Send a 12-byte packet: 4 zero bytes + 8-byte identifier. +/// Receive a 24-byte response: 4 bytes version + 8 bytes identifier echo +/// + 4 bytes current_users + 4 bytes max_users + 4 bytes bandwidth. +#[cfg(feature = "networking")] +pub async fn ping_server(address: &str, port: u16) -> color_eyre::Result { + use color_eyre::eyre::{bail, eyre}; + use std::net::ToSocketAddrs; + use std::time::Duration; + use tokio::net::UdpSocket; + + let dest = format!("{}:{}", address, port) + .to_socket_addrs()? + .next() + .ok_or_else(|| eyre!("could not resolve address"))?; + + let bind_addr = if dest.is_ipv6() { "[::]:0" } else { "0.0.0.0:0" }; + let socket = UdpSocket::bind(bind_addr).await?; + socket.connect(dest).await?; + + let request_id: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + + let mut buf = [0u8; 12]; + buf[4..12].copy_from_slice(&request_id.to_be_bytes()); + socket.send(&buf).await?; + + let mut response = [0u8; 24]; + let timeout = tokio::time::timeout(Duration::from_secs(2), socket.recv(&mut response)).await; + + match timeout { + Ok(Ok(len)) if len >= 24 => { + let version_major = response[0] as u32; + let version_minor = response[1] as u32; + let version_patch = response[2] as u32; + let users = + u32::from_be_bytes([response[12], response[13], response[14], response[15]]); + let max_users = + u32::from_be_bytes([response[16], response[17], response[18], response[19]]); + let bandwidth = + u32::from_be_bytes([response[20], response[21], response[22], response[23]]); + + Ok(ServerStatus { + success: true, + version: Some((version_major, version_minor, version_patch)), + users: Some(users), + max_users: Some(max_users), + bandwidth: Some(bandwidth), + }) + } + Ok(Ok(_)) => bail!("ping response too short"), + Ok(Err(e)) => Err(e.into()), + Err(_) => bail!("ping timed out"), + } +} diff --git a/gui/src/main.rs b/gui/src/main.rs index dd41eeb..ecdccd4 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -502,15 +502,6 @@ pub fn LoginView(overrides: Resource) -> Element { let user_config = use_context::(); let net: Coroutine = use_coroutine_handle(); - let last_status = use_signal(|| None::>); - use_resource(move || async move { - let client = reqwest::Client::new(); - loop { - *last_status.write_unchecked() = Some(Platform::get_status(&client).await); - Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await; - } - }); - let mut address_input = use_signal(|| user_config.config_get::("server_url")); let address = use_memo(move || { if let Some(addr) = address_input() { @@ -522,6 +513,19 @@ pub fn LoginView(overrides: Resource) -> Element { } }); + let last_status = use_signal(|| None::>); + use_resource(move || { + let addr = address(); + async move { + let client = reqwest::Client::new(); + loop { + *last_status.write_unchecked() = + Some(Platform::get_status(&client, &addr).await); + Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await; + } + } + }); + let mut username = use_signal(|| { user_config .config_get::("username") @@ -640,7 +644,7 @@ pub fn LoginView(overrides: Resource) -> Element { Some(Err(_)) => rsx!(div { class: "login_status is_error", span { - "Could not reach proxy server" + "Could not reach server" } }), } diff --git a/proxy/Cargo.toml b/proxy/Cargo.toml index c10daa8..b12d70e 100644 --- a/proxy/Cargo.toml +++ b/proxy/Cargo.toml @@ -12,7 +12,7 @@ tokio-rustls = "0.26" toml = "0.8" tracing = { version = "^0.1.40", features = ["async-await"] } tracing-subscriber = { version = "^0.3.18", features = ["env-filter"] } -mumble-web2-common = { workspace = true } +mumble-web2-common = { workspace = true, features = ["networking"] } salvo = { version = "^0.84.2", features = [ "quinn", "eyre", @@ -28,4 +28,3 @@ rcgen = "^0.13.2" hmac-sha256 = "^1.1.8" time = "0.3" url = { version = "2", features = ["serde"] } -rand = "0.9.2" diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 1f10a43..5ff2181 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -1,6 +1,5 @@ use color_eyre::eyre::{anyhow, bail, Context, Result}; -use mumble_web2_common::{ProxyOverrides, ServerStatus}; -use rand::Rng; +use mumble_web2_common::{ping_server, ProxyOverrides, ServerStatus}; use salvo::conn::rustls::{Keycert, RustlsConfig}; use salvo::cors::{AllowOrigin, Cors}; use salvo::logging::Logger; @@ -26,8 +25,6 @@ use tracing_subscriber::filter::LevelFilter; use tracing_subscriber::EnvFilter; use url::Url; -mod ping; - fn default_cert_alt_names() -> Vec { vec!["localhost".into()] } @@ -179,70 +176,14 @@ pub struct StatusCraft { impl StatusCraft { #[craft(handler)] async fn get_status(&self) -> Json { - let mut server_status = ServerStatus::default(); - - let ping_packet = ping::PingPacket { - id: rand::rng().random(), - }; - - let sock = match tokio::net::UdpSocket::bind("0.0.0.0:0").await { - Ok(s) => s, + let addr = self.mumble_server_address; + match ping_server(&addr.ip().to_string(), addr.port()).await { + Ok(status) => Json(status), Err(e) => { - error!("Could not bind udp socket: {}", e); - return Json(server_status); - } - }; - - match sock.connect(self.mumble_server_address).await { - Ok(_) => {} - Err(e) => { - error!("Could not send ping packet: {}", e); - return Json(server_status); + error!("ping failed: {e:#}"); + Json(ServerStatus::default()) } } - - match sock.send(&<[u8; 12]>::from(ping_packet)).await { - Ok(_) => {} - Err(e) => { - error!("Could not send ping packet"); - return Json(server_status); - } - } - - let mut pong_buf: [u8; 24] = [0; 24]; - - match tokio::time::timeout( - tokio::time::Duration::from_secs(1), - sock.recv(&mut pong_buf), - ) - .await - { - Ok(_) => {} - Err(e) => { - error!("Could not send ping packet"); - return Json(server_status); - } - } - - let pong_packet = match ping::PongPacket::try_from(pong_buf.as_slice()) { - Ok(p) => p, - Err(e) => { - error!("Could not parse pong packet: {:?}", e); - return Json(server_status); - } - }; - - server_status.success = true; - server_status.version = Some(( - pong_packet.version & 0xFF, - (pong_packet.version >> 8) & 0xFF, - (pong_packet.version >> 16) & 0xFF, - )); - server_status.users = Some(pong_packet.users); - server_status.max_users = Some(pong_packet.max_users); - server_status.bandwidth = Some(pong_packet.bandwidth); - - Json(server_status) } } diff --git a/proxy/src/ping.rs b/proxy/src/ping.rs deleted file mode 100644 index fe4a757..0000000 --- a/proxy/src/ping.rs +++ /dev/null @@ -1,141 +0,0 @@ -// This code was taken from mumble-protocol-2x (https://github.com/dblsaiko/rust-mumble-protocol) -// and originally from mumble-protocol (https://github.com/Johni0702/rust-mumble-protocol) -// These projects are licensed under MIT and Apache 2.0. - -//! Ping messages and codec -//! -//! A Mumble client can send periodic UDP [PingPacket]s to servers -//! in order to query their current state and measure latency. -//! A server will usually respond with a corresponding [PongPacket] containing -//! the requested details. -//! -//! Both packets are of fixed size and can be converted to/from `u8` arrays/slices via -//! the respective `From`/`TryFrom` impls. - -/// A ping packet sent to the server. -#[derive(Clone, Debug, PartialEq)] -pub struct PingPacket { - /// Opaque, client-generated id. - /// - /// Will be returned by the server unmodified and can be used to correlate - /// pong replies to ping requests to e.g. calculate latency. - pub id: u64, -} - -/// A pong packet sent to the client in reply to a previously received [PingPacket]. -#[derive(Clone, Debug, PartialEq)] -pub struct PongPacket { - /// Opaque, client-generated id. - /// - /// Should match the value in the corresponding [PingPacket]. - pub id: u64, - - /// Server version. E.g. `0x010300` for `1.3.0`. - pub version: u32, - - /// Current amount of users connected to the server. - pub users: u32, - - /// Configured limit on the amount of users which can be connected to the server. - pub max_users: u32, - - /// Maximum bandwidth for server-bound speech per client in bits per second - pub bandwidth: u32, -} - -/// Error during parsing of a [PingPacket]. -#[derive(Clone, Debug, PartialEq)] -pub enum ParsePingError { - /// Ping packets must always be 12 bytes in size. - InvalidSize, - /// Ping packets must have an all zero header of 4 bytes. - InvalidHeader, -} - -impl TryFrom<&[u8]> for PingPacket { - type Error = ParsePingError; - fn try_from(buf: &[u8]) -> Result { - match <[u8; 12]>::try_from(buf) { - Ok(array) => { - if array[0..4] != [0, 0, 0, 0] { - Err(ParsePingError::InvalidHeader) - } else { - Ok(Self { - id: u64::from_be_bytes(array[4..12].try_into().unwrap()), - }) - } - } - Err(_) => Err(ParsePingError::InvalidSize), - } - } -} - -impl From for [u8; 12] { - fn from(packet: PingPacket) -> Self { - let id = packet.id.to_be_bytes(); - // Is there no nicer way to do this? - [ - 0, 0, 0, 0, id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7], - ] - } -} - -/// Error during parsing of a [PongPacket]. -#[derive(Clone, Debug, PartialEq)] -pub enum ParsePongError { - /// Pong packets must always be 24 bytes in size. - InvalidSize, -} - -impl TryFrom<&[u8]> for PongPacket { - type Error = ParsePongError; - fn try_from(buf: &[u8]) -> Result { - match <[u8; 24]>::try_from(buf) { - Ok(array) => Ok(Self { - version: u32::from_be_bytes(array[0..4].try_into().unwrap()), - id: u64::from_be_bytes(array[4..12].try_into().unwrap()), - users: u32::from_be_bytes(array[12..16].try_into().unwrap()), - max_users: u32::from_be_bytes(array[16..20].try_into().unwrap()), - bandwidth: u32::from_be_bytes(array[20..24].try_into().unwrap()), - }), - Err(_) => Err(ParsePongError::InvalidSize), - } - } -} - -impl From for [u8; 24] { - fn from(packet: PongPacket) -> Self { - let version = packet.version.to_be_bytes(); - let id = packet.id.to_be_bytes(); - let users = packet.users.to_be_bytes(); - let max_users = packet.max_users.to_be_bytes(); - let bandwidth = packet.bandwidth.to_be_bytes(); - // Is there no nicer way to do this? - [ - version[0], - version[1], - version[2], - version[3], - id[0], - id[1], - id[2], - id[3], - id[4], - id[5], - id[6], - id[7], - users[0], - users[1], - users[2], - users[3], - max_users[0], - max_users[1], - max_users[2], - max_users[3], - bandwidth[0], - bandwidth[1], - bandwidth[2], - bandwidth[3], - ] - } -}