Compare commits

..

No commits in common. "main" and "v1.0.0" have entirely different histories.
main ... v1.0.0

8 changed files with 63 additions and 359 deletions

2
Cargo.lock generated
View file

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

View file

@ -4,7 +4,7 @@ authors = ["ari melody <ari@arimelody.me>"]
repository = "https://git.arimelody.me/ari/mcstatusface"
license = "MIT"
keywords = ["minecraft", "server", "query", "web"]
version = "1.1.1"
version = "1.0.0"
edition = "2024"
[dependencies]

View file

@ -3,18 +3,11 @@
A light application that serves Minecraft server query information in a
convenient format!
For more information, see https://minecraft.wiki/w/Query
## Usage
McStatusFace can simply be run with `mcstatusface <address:[port]>`.
McStatusFace can be run as a web server with `./mcstatusface serve`. This will
provide server information in JSON format to requests on `GET /?s=<address[:port]>`.
(e.g. `curl -sS "127.0.0.1:8080?s=127.0.0.1:25565" | jq .`)
Alternatively, you can start a web interface with
`mcstatusface serve [address:[port]]`.
This provides a web interface to query server information via a frontend, or
in JSON format if the request is cURLed.
The server queries Minecraft servers when a `GET /?s=<address[:port]>` request
is received. (e.g. `curl -sS "127.0.0.1:8080?s=127.0.0.1:25565" | jq .`)
Alternatively, you can simply run `./mcstatusface <address[:port]>`, and the
tool will provide server details in plain-text format.

View file

@ -1,10 +1,4 @@
use std::{
collections::HashMap,
io::{BufRead, BufReader, Error, ErrorKind, Result, Write},
net::{IpAddr, SocketAddr, TcpListener, TcpStream},
str::FromStr,
sync::Arc,
};
use std::{collections::HashMap, io::{BufRead, BufReader, Result, Write}, net::{SocketAddr, TcpListener, TcpStream}};
use chrono::Local;
@ -48,27 +42,22 @@ pub struct Request<'a> {
path: &'a str,
method: &'a str,
version: &'a str,
headers: HashMap<String, &'a str>,
headers: HashMap<&'a str, &'a str>,
query: HashMap<&'a str, &'a str>,
body: Option<String>,
real_address: IpAddr,
}
impl<'a> Request<'a> {
pub fn new(stream: &'a TcpStream, lines: &'a Vec<String>, trusted_proxies: Vec<IpAddr>) -> Result<Request<'a>> {
pub fn new(stream: &'a TcpStream, lines: &'a Vec<String>) -> Result<Request<'a>> {
let request_line = lines[0].as_str();
let request_line_split: Vec<&str> = request_line.split(" ").collect();
if request_line_split.len() < 3 {
return Err(Error::new(ErrorKind::Other, "invalid request start-line"));
}
let method = request_line_split[0];
let mut path = request_line_split[1];
let path = request_line_split[1];
let version = request_line_split[2];
let mut query: HashMap<&'a str, &'a str> = HashMap::new();
match request_line_split[1].split_once("?") {
Some((path_without_query, query_string)) => {
path = path_without_query;
match path.split_once("?") {
Some((_, query_string)) => {
let query_splits: Vec<&'a str> = query_string.split("&").collect();
for pair in query_splits {
match pair.split_once("=") {
@ -82,7 +71,7 @@ impl<'a> Request<'a> {
None => {},
}
let mut headers: HashMap<String, &'a str> = HashMap::new();
let mut headers: HashMap<&'a str, &'a str> = HashMap::new();
if lines.len() > 1 {
let mut i: usize = 1;
loop {
@ -90,27 +79,11 @@ impl<'a> Request<'a> {
let line = &lines[i];
if line.len() == 0 || !line.contains(":") { break; }
let (name, value) = line.split_once(":").unwrap();
headers.insert(name.to_lowercase(), value.trim());
headers.insert(name, value.trim());
i += 1;
}
}
let mut real_address = IpAddr::from(stream.peer_addr().unwrap().ip());
headers.get("X-Forwarded-For").inspect(|address| {
match IpAddr::from_str(address) {
Ok(address) => {
for proxy in trusted_proxies {
if real_address == proxy {
real_address = address;
break;
}
}
}
Err(_) => {}
}
});
let mut body: Option<String> = None;
if lines.len() > headers.len() + 2 &&
(method == "POST" || method == "PUT") &&
@ -127,16 +100,12 @@ impl<'a> Request<'a> {
headers,
query,
body,
real_address,
})
}
pub fn address(&self) -> Result<SocketAddr> {
self.stream.peer_addr()
}
pub fn real_address(&self) -> &IpAddr {
&self.real_address
}
pub fn path(&self) -> &'a str {
self.path
}
@ -149,7 +118,7 @@ impl<'a> Request<'a> {
pub fn body(&self) -> &Option<String> {
&self.body
}
pub fn headers(&self) -> &HashMap<String, &'a str> {
pub fn headers(&self) -> &HashMap<&'a str, &'a str> {
&self.headers
}
pub fn query(&self) -> &HashMap<&'a str, &'a str> {
@ -214,12 +183,11 @@ impl<'a> Response<'a> {
pub struct HttpServer {
address: String,
port: u16,
trusted_proxies: Arc<Vec<IpAddr>>,
max_connections: usize,
}
impl HttpServer {
pub fn new(address: String, max_connections: usize, trusted_proxies: Vec<IpAddr>) -> HttpServer {
pub fn new(address: String, max_connections: usize) -> HttpServer {
let mut _address = address.clone();
let mut _port: u16 = 8080;
match address.split_once(":") {
@ -232,7 +200,6 @@ impl HttpServer {
HttpServer {
address: _address,
port: _port,
trusted_proxies: Arc::new(trusted_proxies),
max_connections,
}
}
@ -246,9 +213,8 @@ impl HttpServer {
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let trusted_proxies = self.trusted_proxies.clone();
pool.execute(move || {
HttpServer::handle_client(&stream, handler, trusted_proxies);
HttpServer::handle_client(&stream, handler);
});
}
Err(e) => {
@ -260,7 +226,7 @@ impl HttpServer {
Ok(())
}
fn handle_client(stream: &TcpStream, handler: HttpHandlerFunc, trusted_proxies: Arc<Vec<IpAddr>>) {
fn handle_client(stream: &TcpStream, handler: HttpHandlerFunc) {
let buf_reader = BufReader::new(stream);
let http_request: Vec<String> = buf_reader
.lines()
@ -268,7 +234,7 @@ impl HttpServer {
.take_while(|line| !line.is_empty())
.collect();
let request = Request::new(stream, &http_request, trusted_proxies.to_vec());
let request = Request::new(stream, &http_request);
if request.is_err() {
eprintln!("Failed to process request: {}", request.err().unwrap());
return;
@ -281,7 +247,6 @@ impl HttpServer {
match handler(&request, response) {
Ok(status) => {
let end_date = Local::now();
println!(
"[{}] {} {} {} - {} {} - {}ms - {} ({})",
start_date.format("%Y-%m-%d %H:%M:%S"),
@ -296,7 +261,7 @@ impl HttpServer {
(end_date - start_date).num_milliseconds(),
request.headers().get("User-Agent").map_or("[]", |v| v),
request.real_address().to_string(),
request.address().unwrap().ip(),
);
}
Err(e) => {

View file

@ -1,32 +1,15 @@
use std::io::{Result};
use std::net::{IpAddr, ToSocketAddrs};
use std::str::FromStr;
use std::{env};
use std::net::{ToSocketAddrs};
use std::env;
use mcstatusface::http::{HttpServer, StatusCode};
use mcstatusface::{MinecraftStatus};
#[derive(serde::Serialize)]
struct MinecraftStatusResponse<'a> {
version: &'a String,
players: u32,
max_players: u32,
motd: String,
}
fn main() -> Result<()> {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
println!(
r#"Crafty McStatusFace, v{} - made with <3 by ari melody
Host a web API:
$ mcstatusface serve [address[:port]]
Query a server:
$ mcstatusface <address[:port]>"#,
env!("CARGO_PKG_VERSION"));
std::process::exit(0);
eprintln!("Usage: {} [serve] <address[:port]>", args[0]);
std::process::exit(1);
}
if args[1] != "serve" {
@ -47,24 +30,9 @@ env!("CARGO_PKG_VERSION"));
let mut address = "0.0.0.0:8080".to_string();
if args.len() > 2 { address = args[2].to_string() }
let trusted_proxies: Vec<IpAddr> =
match env::var("MCSTATUSFACE_TRUSTED_PROXIES") {
Err(_) => { vec![] }
Ok(envar) => {
let mut trusted_proxies: Vec<IpAddr> = Vec::new();
for addr in envar.split(",") {
match IpAddr::from_str(addr) {
Err(_) => {}
Ok(addr) => { trusted_proxies.push(addr); }
}
}
trusted_proxies
}
};
HttpServer::new(address, 64, trusted_proxies).start(|request, mut response| {
HttpServer::new(address, 64).start(|request, mut response| {
response.status(StatusCode::OK);
response.set_header("Content-Type", "text/plain".to_string());
response.set_header("x-powered-by", "GIRL FUEL".to_string());
if request.method() != "GET" {
@ -72,159 +40,51 @@ env!("CARGO_PKG_VERSION"));
return response.send()
}
if request.path() == "/style/index.css" {
let content = include_str!("public/style/index.css");
response.set_header("Content-Type", "text/css".to_string());
response.status(StatusCode::OK);
response.body(content.to_string());
return response.send();
if !request.query().contains_key("s") {
response.status(StatusCode::BadRequest);
// TODO: nice index landing page for browsers
response.body("?s=<server address>\n".to_string());
return response.send()
}
if request.path() == "/" {
if request.headers().get("accept").is_some_and(
|accept| accept.contains("text/html")
) {
// HTML response
let content = include_str!("views/index.html");
response.set_header("Content-Type", "text/html".to_string());
let mut address = request.query().get("s").unwrap().to_string();
if !address.contains(":") { address.push_str(":25565"); }
let mut addrs_iter = address.to_socket_addrs().unwrap();
let address = addrs_iter.next().unwrap();
let status = MinecraftStatus::fetch(address).unwrap();
#[derive(serde::Serialize)]
struct MinecraftStatusResponse<'a> {
version: &'a String,
players: u32,
max_players: u32,
motd: String,
}
let minecraft_status = MinecraftStatusResponse{
version: &status.version.name,
players: status.players.online,
max_players: status.players.max,
motd: status.parse_description(),
};
match serde_json::to_string(&minecraft_status) {
Ok(json) => {
response.status(StatusCode::OK);
let query_response: String;
match request.query().get("s") {
None => {
query_response = String::from("");
}
Some(query_address) => {
let mut address = query_address.to_string();
if !address.contains(":") { address.push_str(":25565"); }
match address.to_socket_addrs() {
Err(_) => {
response.status(StatusCode::BadRequest);
query_response = format!(
"<hr/>
<h2>Server Details</h2>
<pre><code>Invalid server address: {}.</pre></code>",
sanitize_html(&address.to_string()),
);
}
Ok(mut addrs_iter) => {
let address = addrs_iter.next().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,
motd: status.parse_description(),
};
query_response = format!(
"<hr/>
<h2>Server Details</h2>
<p>
<strong>Version:</strong> <code>{}</code><br/>
<strong>Players:</strong> <code>{}/{}</code><br/>
<strong>MOTD:</strong>
</p>
<pre id=\"motd\"><code>{}</code></pre>",
sanitize_html(minecraft_status.version).to_string(),
minecraft_status.players,
minecraft_status.max_players,
sanitize_html(&minecraft_status.motd).to_string(),
);
}
}
}
}
}
}
let response_content = content
.replace("{{response}}", &query_response)
.replace("{{host}}", match request.headers().get("host") {
None => { "mcq.bliss.town" }
Some(host) => { host }
});
response.set_header("Content-Type", "text/html".to_string());
response.body(response_content.to_string());
return response.send();
response.set_header("Content-Type", "application/json".to_string());
response.body(json);
}
// JSON response
match request.query().get("s") {
None => {
response.status(StatusCode::OK);
response.body("?s=<server address>\n".to_string());
return response.send();
}
Some(query_address) => {
let mut address = query_address.to_string();
if !address.contains(":") { address.push_str(":25565"); }
match address.to_socket_addrs() {
Err(_) => {
response.status(StatusCode::BadRequest);
response.body("Invalid server address.\n".to_string());
}
Ok(mut addrs_iter) => {
let address = addrs_iter.next().unwrap();
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,
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()
}
Err(e) => {
eprintln!("Request to {address} failed: {e}");
response.status(StatusCode::InternalServerError);
response.set_header("Content-Type", "text/plain".to_string());
response.body("Unable to reach the requested server.\n".to_string());
}
}
response.status(StatusCode::NotFound);
response.body("Not Found".to_string());
return response.send();
response.send()
}).unwrap();
Ok(())
}
fn sanitize_html(input: &String) -> String {
input
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#x27;")
}

View file

@ -1,62 +0,0 @@
:root {
--accent: #7ca82f;
}
body {
font-size: 16px;
font-family: 'Inter', sans-serif;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
form {
width: fit-content;
padding: .5em;
border: 1px solid black;
border-radius: 4px;
}
form input[type="text"] {
width: fit-content;
min-width: 16em;
}
form button {
margin-top: .5em;
padding: .2em .3em;
font-family: inherit;
font-size: inherit;
border: 1px solid black;
border-radius: 4px;
color: white;
background-color: var(--accent);
}
pre code {
padding: .5em;
border: 1px solid black;
border-radius: 4px;
font-size: .8em;
color: #e0e0e0;
background-color: #303030;
}
pre#motd {
margin-top: -1em
}
pre#motd code {
display: block;
width: fit-content;
min-width: 440px;
}
footer {
font-family: monospace;
}

View file

@ -51,10 +51,8 @@ impl MinecraftStatus {
pub fn fetch(address: SocketAddr) -> Result<MinecraftStatus> {
// println!("Connecting to {address}...");
let stream = TcpStream::connect(address.to_string());
if stream.is_err() { return Err(stream.unwrap_err()); }
let mut stream = TcpStream::connect(address.to_string()).unwrap();
// println!("Connected!");
let mut stream = stream.unwrap();
let mut send_buffer: Vec<u8> = Vec::new();

View file

@ -1,50 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Crafty McStatusFace</title>
<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>
<body>
<header>
<h1><a href="/">Crafty McStatusFace</a></h1>
</header>
<hr/>
<main>
<p>
You can use this website to retrieve query information from a Minecraft server!
</p>
<p>
For more information, see <a href="https://minecraft.wiki/w/Query">https://minecraft.wiki/w/Query</a>.
</p>
<form action="/" method="GET">
<label for="s">Server Address</label>
<input type="text" name="s" value="" placeholder="e.g. mc.bliss.town or 104.17.17.42">
<br/>
<button type="submit">Check Status</button>
</form>
<p>
Alternatively, you can cURL this website to get a raw JSON response:
</p>
<pre><code>curl 'https://{{host}}?s=&lt;server address&gt;'</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>
</footer>
</body>
</html>