Compare commits
6 commits
eab99012e8
...
7c8c04573c
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c8c04573c | |||
| 983ac7021e | |||
| d7c4fac34c | |||
| 694c2f07d8 | |||
| 8a30e93a89 | |||
| 394419fa63 |
8 changed files with 270 additions and 39 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -118,7 +118,7 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
|||
|
||||
[[package]]
|
||||
name = "mcstatusface"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
[package]
|
||||
name = "mcstatusface"
|
||||
authors = ["ari melody <ari@arimelody.me>"]
|
||||
repository = "https://git.arimelody.me/ari/mcstatusface"
|
||||
authors = ["ari melody <ari@arimelody.space>"]
|
||||
repository = "https://codeberg.org/arimelody/mcstatusface"
|
||||
license = "MIT"
|
||||
keywords = ["minecraft", "server", "query", "web"]
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
128
src/dns.rs
Normal file
128
src/dns.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ use std::{sync::{mpsc, Arc, Mutex}, thread};
|
|||
pub mod leb128;
|
||||
pub mod status;
|
||||
pub mod http;
|
||||
pub mod dns;
|
||||
pub use status::MinecraftStatus;
|
||||
|
||||
pub struct ThreadPool {
|
||||
|
|
|
|||
46
src/main.rs
46
src/main.rs
|
|
@ -5,15 +5,20 @@ use std::{env};
|
|||
|
||||
use mcstatusface::http::{HttpServer, StatusCode};
|
||||
use mcstatusface::{MinecraftStatus};
|
||||
use mcstatusface::dns::{resolve_srv_port};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct MinecraftStatusResponse<'a> {
|
||||
version: &'a String,
|
||||
players: u32,
|
||||
max_players: u32,
|
||||
enforces_secure_chat: bool,
|
||||
favicon: Option<&'a String>,
|
||||
motd: String,
|
||||
}
|
||||
|
||||
const DEFAULT_PORT: u16 = 25565;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() < 2 {
|
||||
|
|
@ -31,7 +36,15 @@ env!("CARGO_PKG_VERSION"));
|
|||
|
||||
if args[1] != "serve" {
|
||||
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 address = addrs_iter.next().unwrap();
|
||||
|
||||
|
|
@ -95,7 +108,16 @@ env!("CARGO_PKG_VERSION"));
|
|||
}
|
||||
Some(query_address) => {
|
||||
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() {
|
||||
Err(_) => {
|
||||
response.status(StatusCode::BadRequest);
|
||||
|
|
@ -124,6 +146,8 @@ env!("CARGO_PKG_VERSION"));
|
|||
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(),
|
||||
};
|
||||
|
||||
|
|
@ -131,14 +155,19 @@ env!("CARGO_PKG_VERSION"));
|
|||
"<hr/>
|
||||
<h2>Server Details</h2>
|
||||
<p>
|
||||
<strong>Favicon:</strong></br>
|
||||
<img width=\"64\" height=\"64\" src=\"{}\"></br>
|
||||
<strong>Version:</strong> <code>{}</code><br/>
|
||||
<strong>Players:</strong> <code>{}/{}</code><br/>
|
||||
<strong>Enforces Secure Chat:</strong> <code>{}</code></br>
|
||||
<strong>MOTD:</strong>
|
||||
</p>
|
||||
<pre id=\"motd\"><code>{}</code></pre>",
|
||||
sanitize_html(&minecraft_status.favicon.map_or("", |s| s).to_string()),
|
||||
sanitize_html(minecraft_status.version).to_string(),
|
||||
minecraft_status.players,
|
||||
minecraft_status.max_players,
|
||||
if minecraft_status.enforces_secure_chat { "true" } else { "false" },
|
||||
sanitize_html(&minecraft_status.motd).to_string(),
|
||||
);
|
||||
}
|
||||
|
|
@ -168,7 +197,16 @@ env!("CARGO_PKG_VERSION"));
|
|||
}
|
||||
Some(query_address) => {
|
||||
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() {
|
||||
Err(_) => {
|
||||
response.status(StatusCode::BadRequest);
|
||||
|
|
@ -187,6 +225,8 @@ env!("CARGO_PKG_VERSION"));
|
|||
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(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,47 @@
|
|||
: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 {
|
||||
font-size: 16px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
width: 720px;
|
||||
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 {
|
||||
|
|
@ -17,34 +54,45 @@ a:hover {
|
|||
|
||||
form {
|
||||
width: fit-content;
|
||||
padding: .5em;
|
||||
border: 1px solid black;
|
||||
border-radius: 4px;
|
||||
padding: 1em;
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-1);
|
||||
color: var(--fg-1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
form input[type="text"] {
|
||||
width: fit-content;
|
||||
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 {
|
||||
margin-top: .5em;
|
||||
padding: .2em .3em;
|
||||
padding: .2em .4em;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
border: 1px solid black;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--on-accent);
|
||||
background-color: var(--accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
pre code {
|
||||
padding: .5em;
|
||||
border: 1px solid black;
|
||||
border-radius: 4px;
|
||||
font-size: .8em;
|
||||
color: #e0e0e0;
|
||||
background-color: #303030;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
|
||||
background-color: var(--fg-1);
|
||||
color: var(--bg-1);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
pre#motd {
|
||||
|
|
@ -55,6 +103,7 @@ pre#motd code {
|
|||
display: block;
|
||||
width: fit-content;
|
||||
min-width: 440px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
footer {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ pub struct MinecraftPlayer {
|
|||
pub struct MinecraftPlayers {
|
||||
pub online: u32,
|
||||
pub max: u32,
|
||||
pub sample: Vec<MinecraftPlayer>,
|
||||
pub sample: Option<Vec<MinecraftPlayer>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
|
|
@ -30,7 +30,14 @@ enum MinecraftDescriptionExtra {
|
|||
}
|
||||
|
||||
#[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,
|
||||
extra: Option<Vec<MinecraftDescriptionExtra>>,
|
||||
bold: Option<bool>,
|
||||
|
|
@ -103,7 +110,6 @@ impl MinecraftStatus {
|
|||
}
|
||||
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();
|
||||
// println!("{sanitised}");
|
||||
let status: MinecraftStatus = serde_json::from_slice(sanitised.as_bytes()).unwrap();
|
||||
|
||||
Ok(status)
|
||||
|
|
@ -119,6 +125,8 @@ impl MinecraftStatus {
|
|||
}
|
||||
|
||||
fn _description(description: &MinecraftDescription) -> String {
|
||||
match description {
|
||||
MinecraftDescription::Rich(description) => {
|
||||
if description.extra.is_some() {
|
||||
let mut extras = String::new();
|
||||
for extra in description.extra.as_ref().unwrap() {
|
||||
|
|
@ -134,6 +142,11 @@ fn _description(description: &MinecraftDescription) -> String {
|
|||
return description.text.clone() + &extras;
|
||||
}
|
||||
description.text.clone()
|
||||
}
|
||||
MinecraftDescription::Plain(description) => {
|
||||
description.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_packet(stream: &mut TcpStream, data: &[u8]) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<hr/>
|
||||
<main>
|
||||
<p>
|
||||
You can use this website to retrieve query information from a Minecraft server!
|
||||
This website retrieves query information from Minecraft servers!
|
||||
</p>
|
||||
<p>
|
||||
For more information, see <a href="https://minecraft.wiki/w/Query">https://minecraft.wiki/w/Query</a>.
|
||||
|
|
@ -38,13 +38,13 @@
|
|||
Alternatively, you can cURL this website to get a raw JSON response:
|
||||
</p>
|
||||
|
||||
<pre><code>curl 'https://{{host}}?s=<server address>'</code></pre>
|
||||
<pre><code>curl 'https://{{host}}?s=<server address>' | jq .</code></pre>
|
||||
|
||||
{{response}}
|
||||
</main>
|
||||
<hr/>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue