//! 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] [-- ] mchost connect [--local-port ] [--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 Server jar to run. Auto-detected from the server directory when omitted (newest jar with 'server' in its name, else newest jar). -d, --dir Server working directory (where server.properties and world/ live). Default: the jar's directory, else cwd. --java Java executable to use. Default: java on PATH. -m, --memory JVM heap size, e.g. 2G or 4096M. Default: 2G. -p, --port Minecraft listen port. Default: server-port from server.properties, else 25565. --eula Write eula=true into eula.txt (accept the Minecraft EULA). --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 to the cloudflared binary. Default: search PATH and a few common install locations. --restarts 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 Local port the bridge listens on. Default: 8081. --bridge-secret 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 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 Local TCP port to expose for Minecraft. Default: 25565. --bind Local address to listen on. Default: 127.0.0.1 (use 0.0.0.0 to let your whole LAN join through you). --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, pub dir: Option, pub java: String, pub memory: Option, pub port: Option, pub eula: bool, pub tunnel: TunnelMode, pub cloudflared: Option, pub restarts: u32, pub bridge_port: u16, pub bridge_secret: Option, pub bridge: bool, pub public_host: Option, pub extra: Vec, } /// 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, } 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 { let raw: Vec = 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::() .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::() .map_err(|_| format!("invalid restart count: {raw}"))?; } "--bridge-port" => { let raw = value(&arg, &mut it)?; args.bridge_port = raw .parse::() .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) -> Result { it.next() .ok_or_else(|| format!("{flag} requires a value (see mchost --help)")) } fn parse_connect(raw: Vec) -> Result { 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::() .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)) }