//! `mchost connect ` - the player-side helper. //! //! Opens a WebSocket to the tunnel endpoint and exposes it as a local TCP //! port, so a vanilla Minecraft client can join at 127.0.0.1: without //! any mods. Every local connection gets its own tunnel session, so the whole //! party on one machine can play through a single helper instance. use std::time::Duration; use tokio::net::TcpListener; use tokio_tungstenite::MaybeTlsStream; use crate::bridge::pump; use crate::encoding::percent_encode; use crate::ui; pub const LOCAL_PORT_DEFAULT: u16 = 25565; pub fn run(opts: crate::cli::ConnectArgs) -> Result<(), String> { // tokio-tungstenite enables rustls without a crypto provider; pick one so // TLS works regardless of which provider features happen to be unified. let _ = rustls::crypto::ring::default_provider().install_default(); let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .map_err(|e| format!("could not start the async runtime: {e}"))?; runtime.block_on(async_run(opts)) } async fn async_run(opts: crate::cli::ConnectArgs) -> Result<(), String> { let url = normalize_url(&opts.url, opts.secret.as_deref())?; let listener = TcpListener::bind((opts.bind.as_str(), opts.local_port)) .await .map_err(|e| format!("could not listen on {}:{}: {e}", opts.bind, opts.local_port))?; ui::client_ok(&format!( "listening on {}:{} - point your vanilla Minecraft client at that address", opts.bind, opts.local_port )); ui::info(&format!("tunnel endpoint: {url}")); ui::info("press Ctrl-C to quit"); loop { let (local, peer) = listener .accept() .await .map_err(|e| format!("accept failed: {e}"))?; let url = url.clone(); tokio::spawn(async move { ui::client(&format!("player {peer} connecting to the tunnel...")); let started = std::time::Instant::now(); // The tunnel may be briefly unavailable; retry for a while so the // player does not have to click "reconnect" in the Minecraft client. let mut attempt = 0; let connection = loop { // Bound each attempt: DNS for a fresh quick-tunnel name can lag // behind the tunnel coming up. let attempt_result = match tokio::time::timeout( CONNECT_ATTEMPT_TIMEOUT, tokio_tungstenite::connect_async(&url), ) .await { Ok(result) => result, Err(_) => Err(tokio_tungstenite::tungstenite::Error::Io( std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out"), )), }; match attempt_result { Ok((ws, _response)) => break Some(ws), // A refused secret will never succeed; stop wasting the // player's time and let their Minecraft client fail fast. Err(tokio_tungstenite::tungstenite::Error::Http(response)) if matches!( response.status(), tokio_tungstenite::tungstenite::http::StatusCode::UNAUTHORIZED | tokio_tungstenite::tungstenite::http::StatusCode::FORBIDDEN ) => { ui::warn(&format!( "player {peer}: the tunnel refused the connection ({}) - wrong \ --secret? (this helper is using: {})", response.status(), url )); break None; } Err(e) => { attempt += 1; if attempt >= MAX_CONNECT_ATTEMPTS { ui::warn(&format!( "player {peer}: tunnel unreachable after {attempt} attempts: {e}" )); break None; } if attempt == 1 || attempt % 4 == 0 { ui::warn(&format!( "player {peer}: tunnel unreachable ({e}); retrying for up to {}s \ (attempt {attempt}/{MAX_CONNECT_ATTEMPTS})", (MAX_CONNECT_ATTEMPTS - attempt + 1) * (RETRY_DELAY.as_secs() as u32 + CONNECT_ATTEMPT_TIMEOUT.as_secs() as u32) )); } tokio::time::sleep(RETRY_DELAY).await; } } }; if let Some(ws) = connection { ui::client_ok(&format!( "player {peer} is online ({}ms)", started.elapsed().as_millis() )); match pump::>(ws, local).await { Ok(()) => ui::client(&format!("player {peer} disconnected")), Err(e) if e.contains("reset without closing handshake") => { ui::client(&format!("player {peer} disconnected")) } Err(e) => ui::client(&format!("player {peer} connection ended: {e}")), } } }); } } /// Accept `host`, `https://host`, `wss://host/mc`, ... and return a wss:// URL. pub fn normalize_url(raw: &str, secret: Option<&str>) -> Result { let (scheme, rest) = if let Some(rest) = raw.strip_prefix("wss://") { ("wss://", rest) } else if let Some(rest) = raw.strip_prefix("ws://") { ("ws://", rest) } else if let Some(rest) = raw.strip_prefix("mchost://") { // The short invite form shared in-game by the mchost-server mod. ("wss://", rest) } else if let Some(rest) = raw.strip_prefix("https://") { ("wss://", rest) } else if let Some(rest) = raw.strip_prefix("http://") { ("ws://", rest) } else { ("wss://", raw) }; let (authority_and_path, query) = match rest.split_once('?') { Some((left, query)) => (left, Some(query)), None => (rest, None), }; let (authority, path) = match authority_and_path.split_once('/') { Some((authority, path)) => (authority, Some(path)), None => (authority_and_path, None), }; if authority.is_empty() || authority.contains(' ') { return Err(format!("'{raw}' does not look like a tunnel address")); } let path = path.filter(|path| !path.is_empty()).unwrap_or("mc"); let mut query = query.unwrap_or("").to_string(); if let Some(secret) = secret && !query.contains("secret=") { if !query.is_empty() { query.push('&'); } query.push_str(&format!("secret={}", percent_encode(secret))); } let suffix = if query.is_empty() { String::new() } else { format!("?{query}") }; Ok(format!("{scheme}{authority}/{path}{suffix}")) } /// How long the helper waits between retries when it cannot reach the tunnel. pub const RETRY_DELAY: Duration = Duration::from_secs(3); /// How long a single connection attempt may take before it is abandoned. const CONNECT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(15); /// How many times the helper tries to reach the tunnel per player connection. const MAX_CONNECT_ATTEMPTS: u32 = 20;