diff --git a/Cargo.lock b/Cargo.lock index 7a04376..2afe0a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,7 +118,7 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "mcstatusface" -version = "1.0.0" +version = "1.1.1" dependencies = [ "chrono", "serde", diff --git a/Cargo.toml b/Cargo.toml index 70db94f..7140036 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ authors = ["ari melody "] repository = "https://git.arimelody.me/ari/mcstatusface" license = "MIT" keywords = ["minecraft", "server", "query", "web"] -version = "1.0.0" +version = "1.1.1" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 6ab3e2f..10e2605 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,18 @@ 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 be run as a web server with `./mcstatusface serve`. This will -provide server information in JSON format to requests on `GET /?s=`. -(e.g. `curl -sS "127.0.0.1:8080?s=127.0.0.1:25565" | jq .`) +McStatusFace can simply be run with `mcstatusface `. -Alternatively, you can simply run `./mcstatusface `, and the +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=` request +is received. (e.g. `curl -sS "127.0.0.1:8080?s=127.0.0.1:25565" | jq .`) tool will provide server details in plain-text format. diff --git a/src/http.rs b/src/http.rs index 18b406d..131c96e 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,4 +1,10 @@ -use std::{collections::HashMap, io::{BufRead, BufReader, Result, Write}, net::{SocketAddr, TcpListener, TcpStream}}; +use std::{ + collections::HashMap, + io::{BufRead, BufReader, Error, ErrorKind, Result, Write}, + net::{IpAddr, SocketAddr, TcpListener, TcpStream}, + str::FromStr, + sync::Arc, +}; use chrono::Local; @@ -42,22 +48,27 @@ pub struct Request<'a> { path: &'a str, method: &'a str, version: &'a str, - headers: HashMap<&'a str, &'a str>, + headers: HashMap, query: HashMap<&'a str, &'a str>, body: Option, + real_address: IpAddr, } impl<'a> Request<'a> { - pub fn new(stream: &'a TcpStream, lines: &'a Vec) -> Result> { + pub fn new(stream: &'a TcpStream, lines: &'a Vec, trusted_proxies: Vec) -> Result> { 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 path = request_line_split[1]; + let mut path = request_line_split[1]; let version = request_line_split[2]; let mut query: HashMap<&'a str, &'a str> = HashMap::new(); - match path.split_once("?") { - Some((_, query_string)) => { + match request_line_split[1].split_once("?") { + Some((path_without_query, query_string)) => { + path = path_without_query; let query_splits: Vec<&'a str> = query_string.split("&").collect(); for pair in query_splits { match pair.split_once("=") { @@ -71,7 +82,7 @@ impl<'a> Request<'a> { None => {}, } - let mut headers: HashMap<&'a str, &'a str> = HashMap::new(); + let mut headers: HashMap = HashMap::new(); if lines.len() > 1 { let mut i: usize = 1; loop { @@ -79,11 +90,27 @@ 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, value.trim()); + headers.insert(name.to_lowercase(), 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 = None; if lines.len() > headers.len() + 2 && (method == "POST" || method == "PUT") && @@ -100,12 +127,16 @@ impl<'a> Request<'a> { headers, query, body, + real_address, }) } pub fn address(&self) -> Result { self.stream.peer_addr() } + pub fn real_address(&self) -> &IpAddr { + &self.real_address + } pub fn path(&self) -> &'a str { self.path } @@ -118,7 +149,7 @@ impl<'a> Request<'a> { pub fn body(&self) -> &Option { &self.body } - pub fn headers(&self) -> &HashMap<&'a str, &'a str> { + pub fn headers(&self) -> &HashMap { &self.headers } pub fn query(&self) -> &HashMap<&'a str, &'a str> { @@ -183,11 +214,12 @@ impl<'a> Response<'a> { pub struct HttpServer { address: String, port: u16, + trusted_proxies: Arc>, max_connections: usize, } impl HttpServer { - pub fn new(address: String, max_connections: usize) -> HttpServer { + pub fn new(address: String, max_connections: usize, trusted_proxies: Vec) -> HttpServer { let mut _address = address.clone(); let mut _port: u16 = 8080; match address.split_once(":") { @@ -200,6 +232,7 @@ impl HttpServer { HttpServer { address: _address, port: _port, + trusted_proxies: Arc::new(trusted_proxies), max_connections, } } @@ -213,8 +246,9 @@ 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); + HttpServer::handle_client(&stream, handler, trusted_proxies); }); } Err(e) => { @@ -226,7 +260,7 @@ impl HttpServer { Ok(()) } - fn handle_client(stream: &TcpStream, handler: HttpHandlerFunc) { + fn handle_client(stream: &TcpStream, handler: HttpHandlerFunc, trusted_proxies: Arc>) { let buf_reader = BufReader::new(stream); let http_request: Vec = buf_reader .lines() @@ -234,7 +268,7 @@ impl HttpServer { .take_while(|line| !line.is_empty()) .collect(); - let request = Request::new(stream, &http_request); + let request = Request::new(stream, &http_request, trusted_proxies.to_vec()); if request.is_err() { eprintln!("Failed to process request: {}", request.err().unwrap()); return; @@ -247,6 +281,7 @@ impl HttpServer { match handler(&request, response) { Ok(status) => { let end_date = Local::now(); + println!( "[{}] {} {} {} - {} {} - {}ms - {} ({})", start_date.format("%Y-%m-%d %H:%M:%S"), @@ -261,7 +296,7 @@ impl HttpServer { (end_date - start_date).num_milliseconds(), request.headers().get("User-Agent").map_or("[]", |v| v), - request.address().unwrap().ip(), + request.real_address().to_string(), ); } Err(e) => { diff --git a/src/main.rs b/src/main.rs index 645fec8..a4309af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,32 @@ use std::io::{Result}; -use std::net::{ToSocketAddrs}; -use std::env; +use std::net::{IpAddr, ToSocketAddrs}; +use std::str::FromStr; +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 = env::args().collect(); if args.len() < 2 { - eprintln!("Usage: {} [serve] ", args[0]); - std::process::exit(1); + println!( + r#"Crafty McStatusFace, v{} - made with <3 by ari melody + + Host a web API: +$ mcstatusface serve [address[:port]] + + Query a server: +$ mcstatusface "#, +env!("CARGO_PKG_VERSION")); + std::process::exit(0); } if args[1] != "serve" { @@ -30,9 +47,24 @@ fn main() -> Result<()> { let mut address = "0.0.0.0:8080".to_string(); if args.len() > 2 { address = args[2].to_string() } + let trusted_proxies: Vec = + match env::var("MCSTATUSFACE_TRUSTED_PROXIES") { + Err(_) => { vec![] } + Ok(envar) => { + let mut trusted_proxies: Vec = 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).start(|request, mut response| { + HttpServer::new(address, 64, trusted_proxies).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" { @@ -40,51 +72,159 @@ fn main() -> Result<()> { return response.send() } - if !request.query().contains_key("s") { - response.status(StatusCode::BadRequest); - // TODO: nice index landing page for browsers - response.body("?s=\n".to_string()); - 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(); } - 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) => { + 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()); response.status(StatusCode::OK); - response.set_header("Content-Type", "application/json".to_string()); - response.body(json); + 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!( + "
+

Server Details

+
Invalid server address: {}.
", + 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!( + "
+

Server Details

+
Failed to connect to {}.
", + 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!( + "
+

Server Details

+

+ Version: {}
+ Players: {}/{}
+ MOTD: +

+
{}
", + 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(); } - 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()); + + // JSON response + match request.query().get("s") { + None => { + response.status(StatusCode::OK); + response.body("?s=\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() + } } } - response.send() + response.status(StatusCode::NotFound); + response.body("Not Found".to_string()); + return response.send(); }).unwrap(); Ok(()) } + +fn sanitize_html(input: &String) -> String { + input + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") +} diff --git a/src/public/style/index.css b/src/public/style/index.css new file mode 100644 index 0000000..59c28af --- /dev/null +++ b/src/public/style/index.css @@ -0,0 +1,62 @@ +: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; +} diff --git a/src/status.rs b/src/status.rs index c0363ad..3af55f6 100644 --- a/src/status.rs +++ b/src/status.rs @@ -51,8 +51,10 @@ impl MinecraftStatus { pub fn fetch(address: SocketAddr) -> Result { // 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!"); + let mut stream = stream.unwrap(); let mut send_buffer: Vec = Vec::new(); diff --git a/src/views/index.html b/src/views/index.html new file mode 100644 index 0000000..5bdd9d3 --- /dev/null +++ b/src/views/index.html @@ -0,0 +1,50 @@ + + + + + + Crafty McStatusFace + + + + + + + + + + + +
+

Crafty McStatusFace

+
+
+
+

+ You can use this website to retrieve query information from a Minecraft server! +

+

+ For more information, see https://minecraft.wiki/w/Query. +

+ +
+ + +
+ +
+ +

+ Alternatively, you can cURL this website to get a raw JSON response: +

+ +
curl 'https://{{host}}?s=<server address>'
+ + {{response}} +
+
+ + +