mchost / src/cli.rs

raw file 10608 bytes
//! 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))
}