mchost / src/client.rs

raw file 7726 bytes
//! `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;