Files
SCOPENET-MC/crates/core/src/ping.rs
T
Claude 5dc8a4f2f1 Add Docker image, Compose, CI/CD workflows and docs
- Multi-arch (amd64/arm64) panel image: static musl binary on scratch,
  cross-compiled with cargo-zigbuild (no QEMU), non-root, healthcheck
- docker-compose.yml + .env.example for one-command deployment
- CI: fmt, clippy, tests, web type-checks, Windows launcher build,
  real-network installs of vanilla/Fabric/Quilt/Forge/NeoForge,
  Docker build
- Release: Windows NSIS installer with baked-in panel URL, GHCR image
  push and GitHub release (tag push or manual dispatch)
- README, admin guide, Microsoft auth setup, architecture, development
- rustfmt config and formatting pass

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
2026-09-28 05:19:36 +00:00

266 lines
8.6 KiB
Rust

//! Minecraft Server List Ping: online players, MOTD, version, latency.
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[derive(Debug, Clone, Serialize, Default)]
pub struct ServerStatus {
pub online: bool,
pub version: String,
pub protocol: i64,
pub players_online: i64,
pub players_max: i64,
pub sample: Vec<String>,
/// MOTD using legacy `§` colour codes (rendered by the UI).
pub motd: String,
/// `data:image/png;base64,...`
pub favicon: Option<String>,
pub latency_ms: u64,
}
fn write_varint(buf: &mut Vec<u8>, mut value: i32) {
loop {
let mut byte = (value & 0x7f) as u8;
value = ((value as u32) >> 7) as i32;
if value != 0 {
byte |= 0x80;
}
buf.push(byte);
if value == 0 {
break;
}
}
}
async fn read_varint(stream: &mut TcpStream) -> Result<i32> {
let mut result = 0i32;
for i in 0..5 {
let byte = stream.read_u8().await?;
result |= ((byte & 0x7f) as i32) << (7 * i);
if byte & 0x80 == 0 {
return Ok(result);
}
}
bail!("varint too long")
}
fn packet(id: i32, body: &[u8]) -> Vec<u8> {
let mut inner = Vec::new();
write_varint(&mut inner, id);
inner.extend_from_slice(body);
let mut out = Vec::new();
write_varint(&mut out, inner.len() as i32);
out.extend(inner);
out
}
#[derive(Deserialize)]
struct RawStatus {
#[serde(default)]
version: Option<RawVersion>,
#[serde(default)]
players: Option<RawPlayers>,
#[serde(default)]
description: Option<serde_json::Value>,
#[serde(default)]
favicon: Option<String>,
}
#[derive(Deserialize)]
struct RawVersion {
#[serde(default)]
name: String,
#[serde(default)]
protocol: i64,
}
#[derive(Deserialize)]
struct RawPlayers {
#[serde(default)]
max: i64,
#[serde(default)]
online: i64,
#[serde(default)]
sample: Vec<RawSample>,
}
#[derive(Deserialize)]
struct RawSample {
#[serde(default)]
name: String,
}
fn color_code(name: &str) -> Option<char> {
Some(match name {
"black" => '0',
"dark_blue" => '1',
"dark_green" => '2',
"dark_aqua" => '3',
"dark_red" => '4',
"dark_purple" => '5',
"gold" => '6',
"gray" => '7',
"dark_gray" => '8',
"blue" => '9',
"green" => 'a',
"aqua" => 'b',
"red" => 'c',
"light_purple" => 'd',
"yellow" => 'e',
"white" => 'f',
_ => return None,
})
}
/// Flatten a chat component into a `§`-coded string.
pub fn flatten_chat(value: &serde_json::Value) -> String {
let mut out = String::new();
fn walk(v: &serde_json::Value, out: &mut String) {
match v {
serde_json::Value::String(s) => out.push_str(s),
serde_json::Value::Array(items) => items.iter().for_each(|i| walk(i, out)),
serde_json::Value::Object(map) => {
if let Some(c) = map.get("color").and_then(|c| c.as_str()) {
if let Some(code) = color_code(c) {
out.push('§');
out.push(code);
} else if c.starts_with('#') && c.len() == 7 {
// Hex colours: emit a custom marker the UI understands.
out.push_str("§#");
out.push_str(&c[1..]);
}
}
for (key, code) in [("bold", 'l'), ("italic", 'o'), ("underlined", 'n'), ("strikethrough", 'm')] {
if map.get(key).and_then(|b| b.as_bool()) == Some(true) {
out.push('§');
out.push(code);
}
}
if let Some(t) = map.get("text").and_then(|t| t.as_str()) {
out.push_str(t);
}
if let Some(extra) = map.get("extra") {
walk(extra, out);
}
if map.contains_key("color") {
out.push_str("§r");
}
}
_ => {}
}
}
walk(value, &mut out);
out
}
pub async fn ping(host: &str, port: u16) -> Result<ServerStatus> {
tokio::time::timeout(Duration::from_secs(5), ping_inner(host, port)).await.context("server did not respond in time")?
}
async fn ping_inner(host: &str, port: u16) -> Result<ServerStatus> {
let started = Instant::now();
let mut stream = TcpStream::connect((host, port)).await.with_context(|| format!("connecting to {host}:{port}"))?;
stream.set_nodelay(true).ok();
let connect_ms = started.elapsed().as_millis() as u64;
let mut body = Vec::new();
write_varint(&mut body, -1); // protocol: "whatever you are"
write_varint(&mut body, host.len() as i32);
body.extend_from_slice(host.as_bytes());
body.extend_from_slice(&port.to_be_bytes());
write_varint(&mut body, 1); // next state: status
stream.write_all(&packet(0x00, &body)).await?;
stream.write_all(&packet(0x00, &[])).await?;
let _len = read_varint(&mut stream).await?;
let id = read_varint(&mut stream).await?;
if id != 0x00 {
bail!("unexpected packet {id}");
}
let str_len = read_varint(&mut stream).await? as usize;
if str_len > 1 << 21 {
bail!("status response too large");
}
let mut json = vec![0u8; str_len];
stream.read_exact(&mut json).await?;
// Latency via ping/pong (fallback: TCP connect time).
let t = Instant::now();
let mut latency_ms = connect_ms;
if stream.write_all(&packet(0x01, &42i64.to_be_bytes())).await.is_ok() {
if let Ok(Ok(_)) = tokio::time::timeout(Duration::from_secs(2), async {
let _ = read_varint(&mut stream).await?;
let _ = read_varint(&mut stream).await?;
stream.read_i64().await.map_err(anyhow::Error::from)
})
.await
{
latency_ms = t.elapsed().as_millis() as u64;
}
}
let raw: RawStatus = serde_json::from_slice(&json).context("invalid status JSON")?;
let players = raw.players.unwrap_or(RawPlayers { max: 0, online: 0, sample: vec![] });
let version = raw.version.unwrap_or(RawVersion { name: String::new(), protocol: 0 });
Ok(ServerStatus {
online: true,
version: version.name,
protocol: version.protocol,
players_online: players.online,
players_max: players.max,
sample: players.sample.into_iter().map(|s| s.name).filter(|n| !n.is_empty()).take(12).collect(),
motd: raw.description.as_ref().map(flatten_chat).unwrap_or_default(),
favicon: raw.favicon,
latency_ms,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn varints() {
let mut b = Vec::new();
write_varint(&mut b, 300);
assert_eq!(b, vec![0xac, 0x02]);
let mut b = Vec::new();
write_varint(&mut b, -1);
assert_eq!(b, vec![0xff, 0xff, 0xff, 0xff, 0x0f]);
}
#[test]
fn flattens_motd() {
let v: serde_json::Value =
serde_json::from_str(r#"{"text":"","extra":[{"text":"Scope","color":"aqua","bold":true},{"text":"Net"}]}"#).unwrap();
assert_eq!(flatten_chat(&v), "§b§lScope§rNet");
assert_eq!(flatten_chat(&serde_json::json!("§aHello")), "§aHello");
}
#[tokio::test]
async fn pings_a_fake_server() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
// read handshake + request (we don't validate them here)
let mut buf = [0u8; 256];
let _ = sock.read(&mut buf).await.unwrap();
let json = br#"{"version":{"name":"1.21.1","protocol":767},"players":{"max":100,"online":7,"sample":[{"name":"Steve","id":"x"}]},"description":"A server"}"#;
let mut body = Vec::new();
write_varint(&mut body, json.len() as i32);
body.extend_from_slice(json);
sock.write_all(&packet(0, &body)).await.unwrap();
let mut ping = [0u8; 64];
let _ = sock.read(&mut ping).await;
sock.write_all(&packet(1, &42i64.to_be_bytes())).await.ok();
});
let s = ping("127.0.0.1", port).await.unwrap();
assert!(s.online);
assert_eq!(s.players_online, 7);
assert_eq!(s.version, "1.21.1");
assert_eq!(s.sample, vec!["Steve"]);
assert_eq!(s.motd, "A server");
}
}