//! `mchost connect <url>` - 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:<port> 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::<MaybeTlsStream<tokio::net::TcpStream>>(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<String, String> {
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;