Compare commits

..

12 commits
v1.1.0 ... main

Author SHA1 Message Date
fc34b1e328
small code cleanup
no functional changes, just minor restructuring
2025-11-06 11:28:49 +00:00
f64882b091
reduce http workers to 16 2025-11-05 20:06:59 +00:00
172a072c36
add enforce secure chat to cli response 2025-11-05 20:04:31 +00:00
7c8c04573c
add checks for SRV records 2025-11-05 19:23:00 +00:00
983ac7021e
add favicon support 2025-11-05 02:18:33 +00:00
d7c4fac34c
report if server enforces secure chat 2025-11-05 02:06:57 +00:00
694c2f07d8
improve query support
simple string MOTDs; optional player sample
2025-11-05 01:58:05 +00:00
8a30e93a89
UI refresh 2025-11-05 01:40:30 +00:00
394419fa63
properly accept ports in web requests 2025-11-05 01:14:45 +00:00
eab99012e8
improve error messages 2025-06-16 22:17:19 +01:00
c765db8148
fix some sloppy unwraps, teensy refactor 2025-06-16 22:05:01 +01:00
75bd0000bb
opengraph metadata 2025-06-16 20:01:25 +01:00
11 changed files with 476 additions and 209 deletions

2
Cargo.lock generated
View file

@ -118,7 +118,7 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]] [[package]]
name = "mcstatusface" name = "mcstatusface"
version = "1.0.1" version = "1.2.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"serde", "serde",

View file

@ -1,10 +1,10 @@
[package] [package]
name = "mcstatusface" name = "mcstatusface"
authors = ["ari melody <ari@arimelody.me>"] authors = ["ari melody <ari@arimelody.space>"]
repository = "https://git.arimelody.me/ari/mcstatusface" repository = "https://codeberg.org/arimelody/mcstatusface"
license = "MIT" license = "MIT"
keywords = ["minecraft", "server", "query", "web"] keywords = ["minecraft", "server", "query", "web"]
version = "1.1.0" version = "1.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

128
src/dns.rs Normal file
View file

@ -0,0 +1,128 @@
use std::{io::{Error, ErrorKind, Result}, net::UdpSocket};
const CLOUDFLARE_DNS: &str = "1.1.1.1";
const RECURSION_DESIRED: u16 = 1 << 8;
const RECORD_TYPE_SRV: u16 = 33;
const RECORD_CLASS_IN: u16 = 1;
pub fn create_dns_query(qname: &String, qtype: u16, qclass: u16) -> Result<Vec<u8>> {
let qname = match qname.is_ascii() {
true => qname,
false => return Err(Error::new(ErrorKind::InvalidInput, "domain is not valid ASCII")),
};
let txid = 1u16;
let flags = RECURSION_DESIRED;
let qdcount = 1u16;
let ancount = 0u16;
let nscount = 0u16;
let arcount = 0u16;
let header: [u16; 6] =
[txid, flags, qdcount, ancount, nscount, arcount]
.map(|el| el.to_be());
// let mut qname_bytes = bitvec![u8, Msb0;];
let mut query = Vec::<u8>::new();
for mut label in qname.split(".") {
label = match label.len() > u8::MAX.into() {
true => label.split_at(u8::MAX.into()).0,
false => label
};
let len: u8 = label.len().try_into().unwrap();
query.push(len);
query.append(&mut label.as_bytes().to_vec());
}
query.push(0);
query.append(&mut qtype.to_be_bytes().to_vec());
query.append(&mut qclass.to_be_bytes().to_vec());
let mut request = Vec::<u8>::new();
for el in header {
request.push(el as u8);
request.push((el >> 8) as u8);
}
request.append(&mut query);
Ok(request)
}
#[derive(Debug)]
pub struct DnsSrvResponse {
qref: u16,
rtype: u16,
class: u16,
ttl: u32,
priority: u16,
weight: u16,
port: u16,
}
pub fn parse_srv_response(mut ptr: usize, recv: &[u8]) -> Option<DnsSrvResponse> {
let mut data = DnsSrvResponse {
qref: 0,
rtype: 0,
class: 0,
ttl: 0,
priority: 0,
weight: 0,
port: 0,
};
fn read16(buf: &[u8], offset: usize) -> u16 {
let left = (buf[offset] as u16) << 8;
let right = buf[offset + 1] as u16;
left + right
}
fn read32(buf: &[u8], offset: usize) -> u32 {
let left = (read16(buf, offset) as u32) << 16;
let right = read16(buf, offset + 2) as u32;
left + right
}
data.qref = read16(recv, ptr);
ptr += 2;
data.rtype = read16(recv, ptr);
ptr += 2;
if data.rtype != RECORD_TYPE_SRV {
return None
}
data.class = read16(recv, ptr);
ptr += 2;
data.ttl = read32(recv, ptr);
ptr += 4;
let _rdata_len = read16(recv, ptr);
ptr += 2;
data.priority = read16(recv, ptr);
ptr += 2;
data.weight = read16(recv, ptr);
ptr += 2;
data.port = read16(recv, ptr);
// optional: read target?
// print!("data: {:#?}\n", data);
Some(data)
}
pub fn resolve_srv_port(domain: &String) -> Option<u16> {
let request = create_dns_query(
&("_minecraft._tcp.".to_string() + domain),
RECORD_TYPE_SRV,
RECORD_CLASS_IN).unwrap();
let socket = UdpSocket::bind("0.0.0.0:0").expect("failed to bind to port!");
socket.connect(CLOUDFLARE_DNS.to_string() + ":53").expect("failed to connect to dns server");
socket.send(&request).expect("failed to send dns request");
let mut recv_buf: [u8; 1024] = [0; 1024];
let _ = socket.recv_from(&mut recv_buf).unwrap();
match parse_srv_response(request.len(), &recv_buf) {
Some(data) => Some(data.port),
None => None,
}
}

View file

@ -8,7 +8,7 @@ use std::{
use chrono::Local; use chrono::Local;
use crate::ThreadPool; use crate::thread::ThreadPool;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub enum StatusCode { pub enum StatusCode {

View file

@ -1,38 +1,34 @@
const CONTINUE_BIT: u8 = 0x80; const CONTINUE_BIT: u8 = 0x80;
pub struct LEB128; pub fn write_leb128(buffer: &mut Vec<u8>, mut value: u64) -> usize {
let mut size: usize = 0;
impl LEB128 { loop {
pub fn write_leb128(buffer: &mut Vec<u8>, mut value: u64) -> usize { let mut byte: u8 = (value & (std::u8::MAX as u64)).try_into().unwrap();
let mut size: usize = 0; value >>= 7;
loop { if value != 0 {
let mut byte: u8 = (value & (std::u8::MAX as u64)).try_into().unwrap(); byte |= CONTINUE_BIT;
value >>= 7;
if value != 0 {
byte |= CONTINUE_BIT;
}
buffer.push(byte);
size += 1;
if value == 0 {
return size;
}
} }
}
pub fn read_leb128(buffer: &[u8]) -> (u32, usize) { buffer.push(byte);
let mut result: u32 = 0; size += 1;
let mut shift: usize = 0;
let mut offset: usize = 0; if value == 0 {
loop { return size;
let byte: u8 = buffer[offset];
result |= ((byte & (std::i8::MAX as u8)) as u32) << (shift as u32);
offset += 1;
if byte & CONTINUE_BIT == 0 || offset == 4 {
return (result, offset);
}
shift += 7;
} }
} }
} }
pub fn read_leb128(buffer: &[u8]) -> (u32, usize) {
let mut result: u32 = 0;
let mut shift: usize = 0;
let mut offset: usize = 0;
loop {
let byte: u8 = buffer[offset];
result |= ((byte & (std::i8::MAX as u8)) as u32) << (shift as u32);
offset += 1;
if byte & CONTINUE_BIT == 0 || offset == 4 {
return (result, offset);
}
shift += 7;
}
}

View file

@ -1,87 +1,5 @@
use std::{sync::{mpsc, Arc, Mutex}, thread};
pub mod leb128; pub mod leb128;
pub mod status; pub mod status;
pub mod thread;
pub mod http; pub mod http;
pub use status::MinecraftStatus; pub mod dns;
pub struct ThreadPool {
workers: Vec<ThreadWorker>,
sender: Option<mpsc::Sender<Job>>,
}
type Job = Box<dyn FnOnce() + Send + 'static>;
impl ThreadPool {
// Create a new ThreadPool with `size` available threads.
//
// # Panics
//
// `new` will panic if `size` is zero.
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
let (sender, receiver) = mpsc::channel();
let receiver = Arc::new(Mutex::new(receiver));
let mut workers = Vec::with_capacity(size);
for id in 0..size {
workers.push(ThreadWorker::new(id, Arc::clone(&receiver)));
}
ThreadPool {
workers,
sender: Some(sender)
}
}
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static
{
let job = Box::new(f);
self.sender.as_ref().unwrap().send(job).unwrap();
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
drop(self.sender.take());
for worker in &mut self.workers.drain(..) {
println!("Shutting down worker {}", worker.id);
worker.thread.join().unwrap();
}
}
}
struct ThreadWorker {
id: usize,
thread: thread::JoinHandle<()>,
}
impl ThreadWorker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> ThreadWorker {
let thread = thread::spawn(move || loop {
let msg = receiver.lock().unwrap().recv();
match msg {
Ok(job) => {
// println!("Job received by worker {id}");
job();
}
Err(_) => {
// println!("Worker {id} disconnected. Shutting down...");
break;
}
}
});
ThreadWorker { id, thread }
}
}

View file

@ -1,19 +1,24 @@
use std::io::{Result}; use std::io::Result;
use std::net::{IpAddr, ToSocketAddrs}; use std::net::{IpAddr, ToSocketAddrs};
use std::str::FromStr; use std::str::FromStr;
use std::{env}; use std::env;
use mcstatusface::http::{HttpServer, StatusCode}; use mcstatusface::http::{HttpServer, StatusCode};
use mcstatusface::{MinecraftStatus}; use mcstatusface::status::MinecraftStatus;
use mcstatusface::dns::resolve_srv_port;
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
struct MinecraftStatusResponse<'a> { struct MinecraftStatusResponse<'a> {
version: &'a String, version: &'a String,
players: u32, players: u32,
max_players: u32, max_players: u32,
enforces_secure_chat: bool,
favicon: Option<&'a String>,
motd: String, motd: String,
} }
const DEFAULT_PORT: u16 = 25565;
fn main() -> Result<()> { fn main() -> Result<()> {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
if args.len() < 2 { if args.len() < 2 {
@ -31,7 +36,15 @@ env!("CARGO_PKG_VERSION"));
if args[1] != "serve" { if args[1] != "serve" {
let mut address = String::from(args[1].as_str()); let mut address = String::from(args[1].as_str());
if !address.contains(":") { address.push_str(":25565"); } if !address.contains(":") {
let port: u16 = match resolve_srv_port(&address) {
Some(port) => port,
None => DEFAULT_PORT,
};
address.push_str(":");
address.push_str(port.to_string().as_str());
}
let mut addrs_iter = address.to_socket_addrs().unwrap(); let mut addrs_iter = address.to_socket_addrs().unwrap();
let address = addrs_iter.next().unwrap(); let address = addrs_iter.next().unwrap();
@ -39,6 +52,10 @@ env!("CARGO_PKG_VERSION"));
println!("Version: {} ({})", status.version.name, status.version.protocol); println!("Version: {} ({})", status.version.name, status.version.protocol);
println!("Players: {}/{}", status.players.online, status.players.max); println!("Players: {}/{}", status.players.online, status.players.max);
println!(
"Enforces Secure Chat: {}",
if status.enforces_secure_chat() { "true" } else { "false" },
);
println!("MOTD:"); println!("MOTD:");
println!("{}", status.parse_description()); println!("{}", status.parse_description());
@ -49,20 +66,20 @@ env!("CARGO_PKG_VERSION"));
if args.len() > 2 { address = args[2].to_string() } if args.len() > 2 { address = args[2].to_string() }
let trusted_proxies: Vec<IpAddr> = let trusted_proxies: Vec<IpAddr> =
match env::var("MCSTATUSFACE_TRUSTED_PROXIES") { match env::var("MCSTATUSFACE_TRUSTED_PROXIES") {
Err(_) => { vec![] }
Ok(envar) => { Ok(envar) => {
let mut trusted_proxies: Vec<IpAddr> = Vec::new(); let mut trusted_proxies: Vec<IpAddr> = Vec::new();
for addr in envar.split(",") { for addr in envar.split(",") {
match IpAddr::from_str(addr) { match IpAddr::from_str(addr) {
Ok(addr) => { trusted_proxies.push(addr); }
Err(_) => {} Err(_) => {}
Ok(addr) => { trusted_proxies.push(addr); }
} }
} }
trusted_proxies trusted_proxies
} }
Err(_) => { vec![] }
}; };
HttpServer::new(address, 64, trusted_proxies).start(|request, mut response| { HttpServer::new(address, 16, trusted_proxies).start(|request, mut response| {
response.status(StatusCode::OK); response.status(StatusCode::OK);
response.set_header("Content-Type", "text/plain".to_string()); response.set_header("Content-Type", "text/plain".to_string());
response.set_header("x-powered-by", "GIRL FUEL".to_string()); response.set_header("x-powered-by", "GIRL FUEL".to_string());
@ -90,56 +107,87 @@ env!("CARGO_PKG_VERSION"));
response.status(StatusCode::OK); response.status(StatusCode::OK);
let query_response: String; let query_response: String;
match request.query().get("s") { match request.query().get("s") {
None => {
query_response = String::from("");
}
Some(query_address) => { Some(query_address) => {
let mut address = query_address.to_string(); let mut address = query_address.to_string();
if !address.contains(":") { address.push_str(":25565"); } address = address.replace("%3A", ":");
if !address.contains(":") {
let port: u16 = match resolve_srv_port(&address) {
Some(port) => port,
None => DEFAULT_PORT,
};
address.push_str(":");
address.push_str(port.to_string().as_str());
}
match address.to_socket_addrs() { match address.to_socket_addrs() {
Err(_) => { Err(_) => {
response.set_header("Content-Type", "text/html".to_string());
response.status(StatusCode::BadRequest); response.status(StatusCode::BadRequest);
response.body("Server address is invalid or unreachable.\n".to_string()); query_response = format!(
return response.send(); "<hr/>
<h2>Server Details</h2>
<pre><code>Invalid server address: {}.</pre></code>",
sanitize_html(&address.to_string()),
);
} }
Ok(mut addrs_iter) => { Ok(mut addrs_iter) => {
let address = addrs_iter.next().unwrap(); let address = addrs_iter.next().unwrap();
let status = MinecraftStatus::fetch(address).unwrap(); match MinecraftStatus::fetch(address) {
Err(_) => {
response.status(StatusCode::InternalServerError);
query_response = format!(
"<hr/>
<h2>Server Details</h2>
<pre><code>Failed to connect to {}.</pre></code>",
sanitize_html(&address.to_string()),
);
}
Ok(status) => {
let minecraft_status = MinecraftStatusResponse{
version: &status.version.name,
players: status.players.online,
max_players: status.players.max,
enforces_secure_chat: status.enforces_secure_chat(),
favicon: status.favicon.as_ref(),
motd: status.parse_description(),
};
let minecraft_status = MinecraftStatusResponse{ query_response = format!(
version: &status.version.name, "<hr/>
players: status.players.online,
max_players: status.players.max,
motd: status.parse_description(),
};
query_response = format!(
"<hr/>
<h2>Server Details</h2> <h2>Server Details</h2>
<p> <p>
<strong>Favicon:</strong></br>
<img width=\"64\" height=\"64\" src=\"{}\"></br>
<strong>Version:</strong> <code>{}</code><br/> <strong>Version:</strong> <code>{}</code><br/>
<strong>Players:</strong> <code>{}/{}</code><br/> <strong>Players:</strong> <code>{}/{}</code><br/>
<strong>Enforces Secure Chat:</strong> <code>{}</code></br>
<strong>MOTD:</strong> <strong>MOTD:</strong>
</p> </p>
<pre id=\"motd\"><code>{}</code></pre>", <pre id=\"motd\"><code>{}</code></pre>",
sanitize_html(&minecraft_status.favicon.map_or("", |s| s).to_string()),
sanitize_html(minecraft_status.version).to_string(), sanitize_html(minecraft_status.version).to_string(),
minecraft_status.players, minecraft_status.players,
minecraft_status.max_players, minecraft_status.max_players,
if minecraft_status.enforces_secure_chat { "true" } else { "false" },
sanitize_html(&minecraft_status.motd).to_string(), sanitize_html(&minecraft_status.motd).to_string(),
); );
}
}
} }
} }
} }
None => {
query_response = String::from("");
}
} }
let response_content = content let response_content = content
.replace("{{response}}", &query_response) .replace("{{response}}", &query_response)
.replace("{{host}}", match request.headers().get("host") { .replace("{{host}}", match request.headers().get("host") {
Some(host) => { host }
None => { "mcq.bliss.town" } None => { "mcq.bliss.town" }
Some(host) => { host }
}); });
response.set_header("Content-Type", "text/html".to_string());
response.body(response_content.to_string()); response.body(response_content.to_string());
return response.send(); return response.send();
} }
@ -153,32 +201,56 @@ env!("CARGO_PKG_VERSION"));
} }
Some(query_address) => { Some(query_address) => {
let mut address = query_address.to_string(); let mut address = query_address.to_string();
if !address.contains(":") { address.push_str(":25565"); } address = address.replace("%3A", ":");
let mut addrs_iter = address.to_socket_addrs().unwrap(); if !address.contains(":") {
let address = addrs_iter.next().unwrap(); let port: u16 = match resolve_srv_port(&address) {
Some(port) => port,
None => DEFAULT_PORT,
};
let status = MinecraftStatus::fetch(address).unwrap(); address.push_str(":");
address.push_str(port.to_string().as_str());
let minecraft_status = MinecraftStatusResponse{ }
version: &status.version.name, match address.to_socket_addrs() {
players: status.players.online, Err(_) => {
max_players: status.players.max, response.status(StatusCode::BadRequest);
motd: status.parse_description(), response.body("Invalid server address.\n".to_string());
};
match serde_json::to_string(&minecraft_status) {
Ok(json) => {
response.status(StatusCode::OK);
response.set_header("Content-Type", "application/json".to_string());
response.body(json + "\n");
} }
Err(e) => { Ok(mut addrs_iter) => {
eprintln!("Request to {address} failed: {e}"); let address = addrs_iter.next().unwrap();
response.status(StatusCode::InternalServerError);
response.body("Unable to reach the requested server.\n".to_string()); match MinecraftStatus::fetch(address) {
Err(_) => {
response.status(StatusCode::InternalServerError);
response.body(format!("Failed to connect to {address}.\n"));
}
Ok(status) => {
let minecraft_status = MinecraftStatusResponse{
version: &status.version.name,
players: status.players.online,
max_players: status.players.max,
enforces_secure_chat: status.enforces_secure_chat(),
favicon: status.favicon.as_ref(),
motd: status.parse_description(),
};
match serde_json::to_string(&minecraft_status) {
Err(e) => {
eprintln!("Failed to format response from {} to JSON: {}", address, e);
response.status(StatusCode::InternalServerError);
response.body("Internal Server Error\n".to_string());
}
Ok(json) => {
response.status(StatusCode::OK);
response.set_header("Content-Type", "application/json".to_string());
response.body(json + "\n");
}
}
}
}
} }
} }
return response.send() return response.send()
} }
} }

View file

@ -1,10 +1,47 @@
:root { :root {
--accent: #7ca82f; --bg-0: #101010;
--bg-1: #181818;
--bg-2: #282828;
--fg-0: #c0c0c0;
--fg-1: #d0d0d0;
--fg-2: #e0e0e0;
--accent: #c0ea76;
--on-accent: #43600f;
--shadow-md: 0 2px 4px rgba(0,0,0,.5);
--shadow-sm: 0 1px 2px rgba(0,0,0,.3);
}
@media (prefers-color-scheme: light) {
:root {
--bg-0: #e8e8e8;
--bg-1: #f0f0f0;
--bg-2: #fcfcfc;
--fg-0: #606060;
--fg-1: #303030;
--fg-2: #101010;
--accent: #6a9321;
--on-accent: #f6ffe6;
--shadow-md: 0 2px 4px rgba(0,0,0,.25);;
--shadow-sm: 0 1px 2px rgba(0,0,0,.15);;
}
} }
body { body {
font-size: 16px; width: 720px;
font-family: 'Inter', sans-serif; max-width: calc(100vw - 1em);
margin: auto;
font-size: 20px;
font-family: 'Inter', 'Arial', sans-serif;
background-color: var(--bg-0);
color: var(--fg-0);
}
* {
transition: background-color .2s ease-out, color .2s ease-out;
} }
a { a {
@ -17,34 +54,45 @@ a:hover {
form { form {
width: fit-content; width: fit-content;
padding: .5em; padding: 1em;
border: 1px solid black; border-radius: 8px;
border-radius: 4px; background-color: var(--bg-1);
color: var(--fg-1);
box-shadow: var(--shadow-md);
} }
form input[type="text"] { form input[type="text"] {
width: fit-content; width: fit-content;
min-width: 16em; min-width: 16em;
padding: .2em .4em;
font-size: inherit;
border: none;
border-radius: 4px;
background-color: var(--bg-2);
color: var(--fg-2);
box-shadow: var(--shadow-sm);
} }
form button { form button {
margin-top: .5em; margin-top: .5em;
padding: .2em .3em; padding: .2em .4em;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
border: 1px solid black; border: none;
border-radius: 4px; border-radius: 8px;
color: white; color: var(--on-accent);
background-color: var(--accent); background-color: var(--accent);
box-shadow: var(--shadow-sm);
} }
pre code { pre code {
padding: .5em; padding: .5em;
border: 1px solid black; border-radius: 8px;
border-radius: 4px; font-size: 16px;
font-size: .8em;
color: #e0e0e0; background-color: var(--fg-1);
background-color: #303030; color: var(--bg-1);
box-shadow: var(--shadow-sm);
} }
pre#motd { pre#motd {
@ -55,6 +103,7 @@ pre#motd code {
display: block; display: block;
width: fit-content; width: fit-content;
min-width: 440px; min-width: 440px;
box-shadow: var(--shadow-sm);
} }
footer { footer {

View file

@ -1,7 +1,7 @@
use std::io::{Error, ErrorKind, Read, Write, Result}; use std::io::{Error, ErrorKind, Read, Write, Result};
use std::net::{SocketAddr, TcpStream}; use std::net::{SocketAddr, TcpStream};
use crate::leb128::LEB128; use crate::leb128::{read_leb128, write_leb128};
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
pub struct MinecraftVersion { pub struct MinecraftVersion {
@ -19,7 +19,7 @@ pub struct MinecraftPlayer {
pub struct MinecraftPlayers { pub struct MinecraftPlayers {
pub online: u32, pub online: u32,
pub max: u32, pub max: u32,
pub sample: Vec<MinecraftPlayer>, pub sample: Option<Vec<MinecraftPlayer>>,
} }
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
@ -30,7 +30,14 @@ enum MinecraftDescriptionExtra {
} }
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
pub struct MinecraftDescription { #[serde(untagged)]
pub enum MinecraftDescription {
Rich(MinecraftRichDescription),
Plain(String)
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct MinecraftRichDescription {
text: String, text: String,
extra: Option<Vec<MinecraftDescriptionExtra>>, extra: Option<Vec<MinecraftDescriptionExtra>>,
bold: Option<bool>, bold: Option<bool>,
@ -51,18 +58,20 @@ impl MinecraftStatus {
pub fn fetch(address: SocketAddr) -> Result<MinecraftStatus> { pub fn fetch(address: SocketAddr) -> Result<MinecraftStatus> {
// println!("Connecting to {address}..."); // println!("Connecting to {address}...");
let mut stream = TcpStream::connect(address.to_string()).unwrap(); let stream = TcpStream::connect(address.to_string());
if stream.is_err() { return Err(stream.unwrap_err()); }
// println!("Connected!"); // println!("Connected!");
let mut stream = stream.unwrap();
let mut send_buffer: Vec<u8> = Vec::new(); let mut send_buffer: Vec<u8> = Vec::new();
// println!("Sending payload..."); // println!("Sending payload...");
send_buffer.push(0x00); send_buffer.push(0x00);
LEB128::write_leb128(&mut send_buffer, 769); // 1.21.4 write_leb128(&mut send_buffer, 769); // 1.21.4
LEB128::write_leb128(&mut send_buffer, address.ip().to_string().len().try_into().unwrap()); write_leb128(&mut send_buffer, address.ip().to_string().len().try_into().unwrap());
send_buffer.extend_from_slice(address.ip().to_string().as_bytes()); send_buffer.extend_from_slice(address.ip().to_string().as_bytes());
send_buffer.extend_from_slice(&address.port().to_be_bytes()); send_buffer.extend_from_slice(&address.port().to_be_bytes());
LEB128::write_leb128(&mut send_buffer, 1); write_leb128(&mut send_buffer, 1);
send_packet(&mut stream, &send_buffer).unwrap(); send_packet(&mut stream, &send_buffer).unwrap();
send_packet(&mut stream, &[0x00]).unwrap(); send_packet(&mut stream, &[0x00]).unwrap();
@ -80,7 +89,7 @@ impl MinecraftStatus {
if len > 0 { if len > 0 {
if msg_len == 0 { if msg_len == 0 {
let mut val: u32; let mut val: u32;
(val, offset) = LEB128::read_leb128(&recv_buffer); (val, offset) = read_leb128(&recv_buffer);
msg_len = val as usize; msg_len = val as usize;
if recv_buffer[offset] != 0x00 { if recv_buffer[offset] != 0x00 {
@ -89,7 +98,7 @@ impl MinecraftStatus {
offset += 1; // skip message type bit offset += 1; // skip message type bit
let offset2: usize; let offset2: usize;
(val, offset2) = LEB128::read_leb128(&recv_buffer[offset..]); (val, offset2) = read_leb128(&recv_buffer[offset..]);
object_len = val as usize; object_len = val as usize;
offset += offset2; offset += offset2;
} }
@ -101,7 +110,6 @@ impl MinecraftStatus {
} }
let msg = std::str::from_utf8(&data[offset..]).unwrap().trim(); let msg = std::str::from_utf8(&data[offset..]).unwrap().trim();
let sanitised: String = msg.chars().filter(|&c| c >= '\u{20}' || c == '\n' || c == '\r' || c == '\t').collect(); let sanitised: String = msg.chars().filter(|&c| c >= '\u{20}' || c == '\n' || c == '\r' || c == '\t').collect();
// println!("{sanitised}");
let status: MinecraftStatus = serde_json::from_slice(sanitised.as_bytes()).unwrap(); let status: MinecraftStatus = serde_json::from_slice(sanitised.as_bytes()).unwrap();
Ok(status) Ok(status)
@ -117,26 +125,33 @@ impl MinecraftStatus {
} }
fn _description(description: &MinecraftDescription) -> String { fn _description(description: &MinecraftDescription) -> String {
if description.extra.is_some() { match description {
let mut extras = String::new(); MinecraftDescription::Rich(description) => {
for extra in description.extra.as_ref().unwrap() { if description.extra.is_some() {
match extra { let mut extras = String::new();
MinecraftDescriptionExtra::Extra(description) => { for extra in description.extra.as_ref().unwrap() {
extras += &_description(&description); match extra {
} MinecraftDescriptionExtra::Extra(description) => {
MinecraftDescriptionExtra::String(string) => { extras += &_description(&description);
extras += &string; }
MinecraftDescriptionExtra::String(string) => {
extras += &string;
}
}
} }
return description.text.clone() + &extras;
} }
description.text.clone()
}
MinecraftDescription::Plain(description) => {
description.clone()
} }
return description.text.clone() + &extras;
} }
description.text.clone()
} }
fn send_packet(stream: &mut TcpStream, data: &[u8]) -> Result<()> { fn send_packet(stream: &mut TcpStream, data: &[u8]) -> Result<()> {
let mut packet: Vec<u8> = Vec::new(); let mut packet: Vec<u8> = Vec::new();
LEB128::write_leb128(&mut packet, data.len() as u64); write_leb128(&mut packet, data.len() as u64);
packet.extend_from_slice(&data); packet.extend_from_slice(&data);
stream.write(&packet).unwrap(); stream.write(&packet).unwrap();

81
src/thread.rs Normal file
View file

@ -0,0 +1,81 @@
use std::{sync::{mpsc, Arc, Mutex}, thread};
pub struct ThreadPool {
workers: Vec<ThreadWorker>,
sender: Option<mpsc::Sender<Job>>,
}
type Job = Box<dyn FnOnce() + Send + 'static>;
impl ThreadPool {
// Create a new ThreadPool with `size` available threads.
//
// # Panics
//
// `new` will panic if `size` is zero.
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
let (sender, receiver) = mpsc::channel();
let receiver = Arc::new(Mutex::new(receiver));
let mut workers = Vec::with_capacity(size);
for id in 0..size {
workers.push(ThreadWorker::new(id, Arc::clone(&receiver)));
}
ThreadPool {
workers,
sender: Some(sender)
}
}
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static
{
let job = Box::new(f);
self.sender.as_ref().unwrap().send(job).unwrap();
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
drop(self.sender.take());
for worker in &mut self.workers.drain(..) {
worker.thread.join().expect(
format!("Error in worker {}", worker.id).as_str());
}
}
}
struct ThreadWorker {
id: usize,
thread: thread::JoinHandle<()>,
}
impl ThreadWorker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> ThreadWorker {
let thread = thread::spawn(move || loop {
let msg = receiver.lock().unwrap().recv();
match msg {
Ok(job) => {
// println!("Job received by worker {id}");
job();
}
Err(_) => {
// println!("Worker {id} disconnected. Shutting down...");
break;
}
}
});
ThreadWorker { id, thread }
}
}

View file

@ -3,8 +3,16 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Minecraft Server Query</title> <title>Crafty McStatusFace</title>
<link href="style/index.css" rel="stylesheet"> <link href="style/index.css" rel="stylesheet">
<meta name="description" content="Query Minecraft servers!">
<meta property="og:title" content="Crafty McStatusFace">
<meta property="og:type" content="website">
<meta property="og:url" content="https://{{host}}">
<meta property="og:site_name" content="Crafty McStatusFace">
<meta property="og:description" content="Query Minecraft servers!">
</head> </head>
<body> <body>
<header> <header>
@ -13,7 +21,7 @@
<hr/> <hr/>
<main> <main>
<p> <p>
You can use this website to retrieve query information from a Minecraft server! This website retrieves query information from Minecraft servers!
</p> </p>
<p> <p>
For more information, see <a href="https://minecraft.wiki/w/Query">https://minecraft.wiki/w/Query</a>. For more information, see <a href="https://minecraft.wiki/w/Query">https://minecraft.wiki/w/Query</a>.
@ -30,13 +38,13 @@
Alternatively, you can cURL this website to get a raw JSON response: Alternatively, you can cURL this website to get a raw JSON response:
</p> </p>
<pre><code>curl 'https://{{host}}?s=&lt;server address&gt;'</code></pre> <pre><code>curl 'https://{{host}}?s=&lt;server address&gt;' | jq .</code></pre>
{{response}} {{response}}
</main> </main>
<hr/> <hr/>
<footer> <footer>
<em>made with <span aria-label="love"></span> by ari, 2025. <a href="https://git.arimelody.me/ari/mcstatusface" target="_blank">source</a></em> <em>made with <span aria-label="love"></span> by ari, 2025. <a href="https://codeberg.org/arimelody/mcstatusface" target="_blank">source</a></em>
</footer> </footer>
</body> </body>
</html> </html>