//! 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 ` 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 ", 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 = 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); } } }