mc-snap 0.6.0

Declarative Minecraft server management. Pin Minecraft, loader, mods, Java, and configs in one YAML file; resolve, install, run, and ship a reproducible bundle.
Documentation
use anyhow::{bail, Context, Result};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;

const TYPE_AUTH: i32 = 3;
const TYPE_EXEC: i32 = 2;
const TYPE_AUTH_RESPONSE: i32 = 2;
const TYPE_RESPONSE: i32 = 0;

pub struct Rcon {
    stream: TcpStream,
    next_id: i32,
}

impl Rcon {
    pub async fn connect(addr: &str, password: &str) -> Result<Self> {
        let stream = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(addr))
            .await
            .context("rcon connect timeout")?
            .with_context(|| format!("connecting to rcon {addr}"))?;
        let mut me = Self { stream, next_id: 1 };
        me.auth(password).await?;
        Ok(me)
    }

    async fn auth(&mut self, password: &str) -> Result<()> {
        let id = self.alloc_id();
        self.write_packet(id, TYPE_AUTH, password.as_bytes())
            .await?;
        let (rid, rtype, _body) = self.read_packet().await?;
        if rtype != TYPE_AUTH_RESPONSE {
            bail!("unexpected rcon auth response type {rtype}");
        }
        if rid == -1 {
            bail!("rcon auth failed: bad password");
        }
        Ok(())
    }

    pub async fn exec(&mut self, cmd: &str) -> Result<String> {
        let id = self.alloc_id();
        self.write_packet(id, TYPE_EXEC, cmd.as_bytes()).await?;
        let (_rid, rtype, body) = self.read_packet().await?;
        if rtype != TYPE_RESPONSE {
            bail!("unexpected rcon exec response type {rtype}");
        }
        Ok(String::from_utf8_lossy(&body).into_owned())
    }

    fn alloc_id(&mut self) -> i32 {
        let id = self.next_id;
        self.next_id = self.next_id.wrapping_add(1);
        id
    }

    async fn write_packet(&mut self, id: i32, ptype: i32, body: &[u8]) -> Result<()> {
        let length = (4 + 4 + body.len() + 2) as i32;
        let mut buf = Vec::with_capacity(4 + length as usize);
        buf.extend_from_slice(&length.to_le_bytes());
        buf.extend_from_slice(&id.to_le_bytes());
        buf.extend_from_slice(&ptype.to_le_bytes());
        buf.extend_from_slice(body);
        buf.push(0);
        buf.push(0);
        self.stream.write_all(&buf).await?;
        Ok(())
    }

    async fn read_packet(&mut self) -> Result<(i32, i32, Vec<u8>)> {
        let mut len_buf = [0u8; 4];
        self.stream.read_exact(&mut len_buf).await?;
        let length = i32::from_le_bytes(len_buf);
        // Minecraft caps the response body at 4096 bytes; the length field also
        // covers id (4) + type (4) + two NUL terminators, so 4106 is the real max.
        if !(10..=4106).contains(&length) {
            bail!("rcon packet length out of range: {length}");
        }
        let mut payload = vec![0u8; length as usize];
        self.stream.read_exact(&mut payload).await?;
        let id = i32::from_le_bytes(payload[0..4].try_into().unwrap());
        let ptype = i32::from_le_bytes(payload[4..8].try_into().unwrap());
        let body_end = payload.len().saturating_sub(2);
        let body = payload[8..body_end].to_vec();
        Ok((id, ptype, body))
    }
}