//! 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);
}
}
}