//! Command line parsing (hand rolled, no dependencies).
use std::path::PathBuf;
pub const HELP: &str = "\
mchost - run a Minecraft server jar and expose it through a Cloudflare Tunnel
USAGE:
mchost [OPTIONS] [SERVER_JAR] [-- <extra args for the server jar>]
mchost connect <URL> [--local-port <PORT>] [--secret <SECRET>]
EXAMPLES:
mchost server.jar quick tunnel: random *.trycloudflare.com name
mchost server.jar --token eyJhIjoi... named tunnel: served from your purchased domain
mchost -m 4G --eula paper.jar accept the Minecraft EULA and give the JVM 4G
mchost --no-tunnel server.jar just run the server, no tunnel
mchost server.jar -- -XX:+UseG1GC pass extra arguments to the server jar
mchost connect https://funny-name.trycloudflare.com
player side: expose the tunnel as
127.0.0.1:25565 for a vanilla client
SERVER OPTIONS:
-j, --jar <PATH> Server jar to run. Auto-detected from the server
directory when omitted (newest jar with 'server' in
its name, else newest jar).
-d, --dir <PATH> Server working directory (where server.properties and
world/ live). Default: the jar's directory, else cwd.
--java <PATH> Java executable to use. Default: java on PATH.
-m, --memory <SIZE> JVM heap size, e.g. 2G or 4096M. Default: 2G.
-p, --port <PORT> Minecraft listen port. Default: server-port from
server.properties, else 25565.
--eula Write eula=true into eula.txt (accept the Minecraft EULA).
--token <TOKEN> Cloudflare Tunnel token from the Zero Trust dashboard
(Networks -> Tunnels). Serves your purchased domain
instead of a random trycloudflare.com name.
--no-tunnel Run the Minecraft server without any tunnel.
--cloudflared <PATH> Path to the cloudflared binary. Default: search PATH
and a few common install locations.
--restarts <N> How many times to restart a flaky quick tunnel with a
fresh random name. Default: 5.
-h, --help Show this help.
-V, --version Show version.
WEBSOCKET BRIDGE (lets players join through the tunnel with `mchost connect`):
--bridge-port <PORT> Local port the bridge listens on. Default: 8081.
--bridge-secret <S> Require this secret from joining players
(recommended: it keeps strangers out of your link).
--no-bridge Disable the bridge; the tunnel then points straight
at the Minecraft TCP port (only useful with
Cloudflare Spectrum or a client that speaks TCP).
--public-host <HOST> Your tunnel's public hostname (e.g. mc.example.com,
token mode). Lets mchost print the shareable
mchost:// invite and feed it to the mchost-server
mod in-game.
CONNECT OPTIONS (player side):
--local-port <PORT> Local TCP port to expose for Minecraft. Default: 25565.
--bind <ADDR> Local address to listen on. Default: 127.0.0.1
(use 0.0.0.0 to let your whole LAN join through you).
--secret <SECRET> The server's --bridge-secret value, if it has one.
CONSOLE COMMANDS (type while the server is running):
stop | end | quit Gracefully stop the server (same as typing 'stop').
tunnel | url Show the current tunnel address and join instructions.
help Show these commands.
anything else Forwarded to the Minecraft server console.
";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TunnelMode {
None,
/// Account-less quick tunnel with a random *.trycloudflare.com name.
Quick,
/// Named tunnel driven by a token, served from a purchased domain.
Token(String),
}
#[derive(Debug, Clone)]
pub struct Args {
pub jar: Option<PathBuf>,
pub dir: Option<PathBuf>,
pub java: String,
pub memory: Option<String>,
pub port: Option<u16>,
pub eula: bool,
pub tunnel: TunnelMode,
pub cloudflared: Option<PathBuf>,
pub restarts: u32,
pub bridge_port: u16,
pub bridge_secret: Option<String>,
pub bridge: bool,
pub public_host: Option<String>,
pub extra: Vec<String>,
}
/// Arguments for the `mchost connect` player-side helper.
#[derive(Debug, Clone)]
pub struct ConnectArgs {
pub url: String,
pub bind: String,
pub local_port: u16,
pub secret: Option<String>,
}
impl Default for Args {
fn default() -> Self {
Self {
jar: None,
dir: None,
java: "java".to_string(),
memory: None,
port: None,
eula: false,
tunnel: TunnelMode::Quick,
cloudflared: None,
restarts: 5,
bridge_port: crate::bridge::BRIDGE_PORT_DEFAULT,
bridge_secret: None,
bridge: true,
public_host: None,
extra: Vec::new(),
}
}
}
pub enum ParseOutcome {
Run(Args),
Connect(ConnectArgs),
Help,
Version,
}
pub fn parse() -> Result<ParseOutcome, String> {
let raw: Vec<String> = std::env::args().skip(1).collect();
if raw.first().map(String::as_str) == Some("connect") {
return parse_connect(raw[1..].to_vec());
}
let mut args = Args::default();
let mut it = raw.into_iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"-h" | "--help" => return Ok(ParseOutcome::Help),
"-V" | "--version" => return Ok(ParseOutcome::Version),
"--" => {
args.extra.extend(it);
break;
}
"-j" | "--jar" => args.jar = Some(PathBuf::from(value(&arg, &mut it)?)),
"-d" | "--dir" => args.dir = Some(PathBuf::from(value(&arg, &mut it)?)),
"--java" => args.java = value(&arg, &mut it)?,
"-m" | "--memory" => args.memory = Some(value(&arg, &mut it)?),
"-p" | "--port" => {
let raw = value(&arg, &mut it)?;
args.port = Some(
raw.parse::<u16>()
.map_err(|_| format!("invalid port: {raw}"))?,
);
}
"--eula" => args.eula = true,
"--token" => {
let token = value(&arg, &mut it)?;
if token.trim().is_empty() {
return Err("--token requires a non-empty Cloudflare Tunnel token".into());
}
args.tunnel = TunnelMode::Token(token);
}
"--no-tunnel" => args.tunnel = TunnelMode::None,
"--cloudflared" => args.cloudflared = Some(PathBuf::from(value(&arg, &mut it)?)),
"--restarts" => {
let raw = value(&arg, &mut it)?;
args.restarts = raw
.parse::<u32>()
.map_err(|_| format!("invalid restart count: {raw}"))?;
}
"--bridge-port" => {
let raw = value(&arg, &mut it)?;
args.bridge_port = raw
.parse::<u16>()
.map_err(|_| format!("invalid bridge port: {raw}"))?;
}
"--bridge-secret" => {
let secret = value(&arg, &mut it)?;
if secret.trim().is_empty() {
return Err("--bridge-secret requires a non-empty value".into());
}
args.bridge_secret = Some(secret);
}
"--no-bridge" => args.bridge = false,
"--public-host" => {
let host = value(&arg, &mut it)?;
if host.is_empty() || host.contains("://") {
return Err("--public-host wants a bare hostname, e.g. mc.example.com".into());
}
args.public_host = Some(host);
}
other if other.starts_with('-') => {
return Err(format!("unknown option '{other}' (see mchost --help)"));
}
other => {
if args.jar.is_some() {
return Err(format!(
"unexpected argument '{}': only one jar may be given",
other
));
}
args.jar = Some(PathBuf::from(other));
}
}
}
Ok(ParseOutcome::Run(args))
}
fn value(flag: &str, it: &mut impl Iterator<Item = String>) -> Result<String, String> {
it.next()
.ok_or_else(|| format!("{flag} requires a value (see mchost --help)"))
}
fn parse_connect(raw: Vec<String>) -> Result<ParseOutcome, String> {
let mut args = ConnectArgs {
url: String::new(),
bind: "127.0.0.1".to_string(),
local_port: crate::client::LOCAL_PORT_DEFAULT,
secret: None,
};
let mut it = raw.into_iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"-h" | "--help" => return Ok(ParseOutcome::Help),
"-p" | "--local-port" => {
let raw = value(&arg, &mut it)?;
args.local_port = raw
.parse::<u16>()
.map_err(|_| format!("invalid local port: {raw}"))?;
}
"--bind" => args.bind = value(&arg, &mut it)?,
"--secret" => args.secret = Some(value(&arg, &mut it)?),
other if other.starts_with('-') => {
return Err(format!(
"unknown connect option '{other}' (see mchost --help)"
));
}
other => {
if !args.url.is_empty() {
return Err(format!(
"unexpected argument '{other}': connect takes one URL"
));
}
args.url = other.to_string();
}
}
}
if args.url.is_empty() {
return Err("mchost connect needs the tunnel URL, e.g. \
mchost connect https://funny-name.trycloudflare.com"
.into());
}
Ok(ParseOutcome::Connect(args))
}