mchost / src/main.rs

raw file 15238 bytes
//! mchost: run a Minecraft server jar and expose it through a Cloudflare Tunnel.

mod bridge;
mod cli;
mod client;
mod encoding;
mod server;
mod tunnel;
mod ui;

use std::io::{self, BufRead};
use std::path::PathBuf;
use std::process::ExitStatus;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};

use cli::{ParseOutcome, TunnelMode};
use server::{LaunchOptions, MinecraftServer};

/// How long a graceful `stop` gets before the server is killed.
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30);

static STOP_REQUESTED: AtomicBool = AtomicBool::new(false);
static FORCE_QUIT: AtomicBool = AtomicBool::new(false);

/// Publish (or clear) the invite file that the mchost-server mod watches.
fn write_address_file(path: &std::path::Path, invite: Option<&str>) {
    let contents = match invite {
        Some(invite) => format!("{invite}\n"),
        None => String::new(),
    };
    if let Err(e) = std::fs::write(path, contents) {
        ui::warn(&format!("could not write {}: {e}", path.display()));
    }
}

enum End {
    Exited(ExitStatus),
    Killed,
}

fn main() {
    let args = match cli::parse() {
        Ok(ParseOutcome::Help) => {
            print!("{}", cli::HELP);
            return;
        }
        Ok(ParseOutcome::Version) => {
            println!("mchost {}", env!("CARGO_PKG_VERSION"));
            return;
        }
        Ok(ParseOutcome::Connect(connect)) => {
            ui::banner("mchost", env!("CARGO_PKG_VERSION"));
            if let Err(message) = client::run(connect) {
                ui::error(&message);
                std::process::exit(1);
            }
            return;
        }
        Ok(ParseOutcome::Run(args)) => args,
        Err(message) => {
            ui::error(&message);
            std::process::exit(2);
        }
    };
    ui::banner("mchost", env!("CARGO_PKG_VERSION"));

    // --- Where the server lives, and which jar to run -----------------------
    let dir = match &args.dir {
        Some(dir) => dir.clone(),
        None => args
            .jar
            .as_deref()
            .and_then(|jar| jar.parent())
            .filter(|parent| !parent.as_os_str().is_empty())
            .map(PathBuf::from)
            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))),
    };
    if !dir.is_dir() {
        ui::error(&format!(
            "server directory does not exist: {}",
            dir.display()
        ));
        std::process::exit(2);
    }

    let jar = match &args.jar {
        Some(jar) => {
            let resolved = if jar.is_absolute() || jar.exists() {
                jar.clone()
            } else {
                dir.join(jar)
            };
            match resolved.canonicalize() {
                Ok(jar) => jar,
                Err(_) => {
                    ui::error(&format!("server jar not found: {}", resolved.display()));
                    std::process::exit(2);
                }
            }
        }
        None => match server::auto_detect_jar(&dir) {
            Some(jar) => {
                ui::info(&format!("using server jar {}", jar.display()));
                jar
            }
            None => {
                ui::error(&format!(
                    "no jar found in {}; run `mchost <server.jar>` to pick one",
                    dir.display()
                ));
                std::process::exit(2);
            }
        },
    };

    // --- Port and other server.properties details ---------------------------
    let props = server::read_server_properties(&dir);
    let port = args.port.unwrap_or_else(|| {
        props
            .get("server-port")
            .and_then(|value| value.parse().ok())
            .unwrap_or(25565)
    });
    if let Some(bind) = props.get("server-ip").filter(|value| !value.is_empty())
        && !matches!(
            bind.as_str(),
            "localhost" | "127.0.0.1" | "0.0.0.0" | "::1" | "::"
        )
    {
        ui::warn(&format!(
            "server.properties sets server-ip={bind}, but the tunnel connects to \
             127.0.0.1:{port}"
        ));
    }
    server::ensure_eula(&dir, args.eula);

    // --- Start the Minecraft server -----------------------------------------
    let launch = LaunchOptions {
        java: args.java.clone(),
        memory: args.memory.clone().or_else(|| Some("2G".to_string())),
        jar: jar.clone(),
        dir: dir.clone(),
        extra_args: args.extra.clone(),
    };
    ui::info(&format!(
        "starting {} (heap {}, port {port})",
        jar.file_name().unwrap_or_default().to_string_lossy(),
        launch.memory.as_deref().unwrap_or("JVM default")
    ));

    let mc = match MinecraftServer::start(&launch) {
        Ok(server) => Arc::new(server),
        Err(e) if e.kind() == io::ErrorKind::NotFound => {
            ui::error(&format!(
                "could not run '{}': not found. Install Java 21+ or pass --java <path>",
                launch.java
            ));
            std::process::exit(2);
        }
        Err(e) => {
            ui::error(&format!("could not start the Minecraft server: {e}"));
            std::process::exit(2);
        }
    };

    // --- Start the websocket bridge (this is what makes the tunnel joinable) --
    let bridge = match &args.tunnel {
        TunnelMode::None => None,
        _ if !args.bridge => {
            ui::warn(
                "websocket bridge disabled (--no-bridge): the tunnel points straight at the \
                 Minecraft TCP port, which only works with Cloudflare Spectrum or a \
                 TCP-speaking client helper",
            );
            None
        }
        _ => Some(bridge::spawn(
            port,
            args.bridge_port,
            args.bridge_secret.clone(),
        )),
    };

    // What the tunnel sends traffic to: the bridge (HTTP) or raw TCP.
    let origin = match &bridge {
        Some(_) => format!("http://127.0.0.1:{}", args.bridge_port),
        None => format!("tcp://127.0.0.1:{port}"),
    };

    // Printed whenever the tunnel reports an address, and on `tunnel` in the console.
    // Also written to mchost-address.txt, which the mchost-server mod reads to
    // announce the invite in-game.
    let address_file = dir.join("mchost-address.txt");
    let join_instructions: tunnel::OnAddress = {
        let secret = args.bridge_secret.clone();
        let bridged = bridge.is_some();
        let address_file = address_file.clone();
        Arc::new(move |address: &str| {
            if !bridged {
                ui::highlight(&[format!("Tunnel address: {address}")]);
                return;
            }
            let mut invite = format!("mchost://{address}/mc");
            let mut command = format!("mchost connect https://{address}");
            if let Some(secret) = &secret {
                invite.push_str(&format!("?s={}", encoding::percent_encode(secret)));
                command.push_str(&format!(" --secret {secret}"));
            }
            ui::highlight(&[
                "Share this invite (the mchost client mod or the CLI accepts it):".to_string(),
                format!("  {invite}"),
                format!("  or: {command}"),
                format!(
                    "Their vanilla Minecraft client then joins 127.0.0.1:{port} - no mods \
                     needed on your side."
                ),
            ]);
            write_address_file(&address_file, Some(&invite));
        })
    };

    // --- Start the tunnel ----------------------------------------------------
    let tunnel = match &args.tunnel {
        TunnelMode::None => {
            ui::info("running without a tunnel (local connections only)");
            None
        }
        mode => {
            let found = tunnel::find_cloudflared(args.cloudflared.clone());
            match found {
                Ok(cloudflared) => {
                    match mode {
                        TunnelMode::Token(_) => {
                            ui::info("starting named tunnel from your Cloudflare token");
                            if bridge.is_some() {
                                ui::info(&format!(
                                    "in the Zero Trust dashboard, point your public hostname at \
                                     this machine: service type HTTP, url http://localhost:{}",
                                    args.bridge_port
                                ));
                            } else {
                                ui::info(&format!(
                                    "in the Zero Trust dashboard, point your public hostname at \
                                     this machine: service type TCP, url tcp://localhost:{port}"
                                ));
                            }
                        }
                        _ => {
                            ui::info(&format!(
                                "starting a quick tunnel (random name) in front of {}",
                                if bridge.is_some() {
                                    format!(
                                        "the websocket bridge on 127.0.0.1:{}",
                                        args.bridge_port
                                    )
                                } else {
                                    format!("the Minecraft TCP port {port}")
                                }
                            ));
                        }
                    }
                    match tunnel::spawn(
                        mode.clone(),
                        cloudflared,
                        origin,
                        args.restarts,
                        Arc::clone(&join_instructions),
                    ) {
                        Ok(tunnel) => Some(tunnel),
                        Err(e) => {
                            ui::error(&format!("could not start the tunnel: {e}"));
                            None
                        }
                    }
                }
                Err(message) => {
                    for line in message.lines() {
                        ui::warn(line);
                    }
                    ui::warn("continuing without a tunnel");
                    None
                }
            }
        }
    };

    // A named tunnel has no random name to announce: use --public-host if given.
    if let TunnelMode::Token(_) = &args.tunnel {
        match &args.public_host {
            Some(host) if bridge.is_some() => join_instructions(host),
            Some(_) => {}
            None => ui::info(
                "tip: pass --public-host mc.example.com so mchost can print the shareable \
                 invite and feed it to the mchost-server mod",
            ),
        }
    }
    if bridge.is_some() {
        ui::info(&format!(
            "invite file for the mchost-server mod: {}",
            address_file.display()
        ));
    }

    // --- Ctrl-C: graceful stop, then force -----------------------------------
    {
        let mc = Arc::clone(&mc);
        if let Err(e) = ctrlc::set_handler(move || {
            if STOP_REQUESTED.swap(true, Ordering::SeqCst) {
                if !FORCE_QUIT.swap(true, Ordering::SeqCst) {
                    println!();
                    ui::warn("forcing shutdown");
                }
                mc.kill();
            } else {
                println!();
                ui::info("interrupt received - stopping the server (Ctrl-C again to force quit)");
                mc.send_line("stop");
            }
        }) {
            ui::warn(&format!("could not install a Ctrl-C handler: {e}"));
        }
    }

    // --- Console: forward input to the server, handle a few commands ---------
    {
        let mc = Arc::clone(&mc);
        let tunnel = tunnel.clone();
        let join_instructions = Arc::clone(&join_instructions);
        std::thread::spawn(move || {
            let stdin = io::stdin();
            for line in stdin.lock().lines().map_while(Result::ok) {
                let line = line.trim();
                if line.is_empty() {
                    continue;
                }
                match line {
                    "stop" | "end" | "quit" | "shutdown" => {
                        if STOP_REQUESTED.swap(true, Ordering::SeqCst) {
                            ui::warn("already stopping");
                        } else {
                            ui::info("stopping the server...");
                            mc.send_line("stop");
                        }
                    }
                    "tunnel" | "url" | "address" => {
                        match tunnel.as_ref().and_then(|tunnel| tunnel.address()) {
                            Some(address) => join_instructions(&address),
                            None => ui::warn(
                                "no random address (a named tunnel serves your own domain)",
                            ),
                        }
                    }
                    "help" | "?" => {
                        ui::info(
                            "commands: stop | tunnel | help - anything else goes to the server",
                        );
                    }
                    other => mc.send_line(other),
                }
            }
        });
    }
    ui::info("type 'stop' to shut down, 'tunnel' for the address, 'help' for commands");

    // --- Wait for the server to finish ---------------------------------------
    let started = Instant::now();
    let mut grace: Option<Instant> = None;
    let end = loop {
        match mc.try_wait() {
            Ok(Some(status)) => break End::Exited(status),
            Ok(None) => {}
            Err(e) => ui::warn(&format!("could not poll the server process: {e}")),
        }

        if FORCE_QUIT.load(Ordering::SeqCst) {
            break End::Killed;
        }
        if STOP_REQUESTED.load(Ordering::SeqCst) && grace.is_none() {
            grace = Some(Instant::now());
            ui::warn(&format!(
                "waiting up to {}s for the server to save and exit",
                SHUTDOWN_TIMEOUT.as_secs()
            ));
        }
        if let Some(grace) = grace
            && grace.elapsed() > SHUTDOWN_TIMEOUT
        {
            ui::warn("server did not stop in time; killing it");
            mc.kill();
            break End::Killed;
        }
        std::thread::sleep(Duration::from_millis(150));
    };

    // --- Clean up -------------------------------------------------------------
    if let Some(tunnel) = &tunnel {
        tunnel.shutdown();
    }
    if let Some(bridge) = &bridge {
        bridge.shutdown();
    }
    // Clear the invite so the in-game mod can tell the tunnel went away.
    write_address_file(&address_file, None);
    match end {
        End::Exited(status) => {
            ui::info(&format!(
                "server exited after {:.0?} ({status})",
                started.elapsed()
            ));
            std::process::exit(status.code().unwrap_or(1));
        }
        End::Killed => {
            ui::info("server killed");
            std::process::exit(1);
        }
    }
}