From 9ac2a523d4afd7b4b12f97171c74c463d842aa34 Mon Sep 17 00:00:00 2001 From: Hyphonical <69310714+Hyphonical@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:41:10 +0000 Subject: [PATCH 1/2] Add `zerotick serve` command Implemented a new `serve` command that provides a Minecraft server status API mirroring the `mcstatus.io` v2 JSON response format. Key features: - API endpoint: `GET /v{major}/status/java/{address}` (major version from Cargo.toml) - In-memory results caching (30 seconds) - Per-IP rate limiting (5 req/sec) - Styled request logging in console - Full MOTD to HTML conversion with Minecraft color codes support - Minimal dependencies added: axum, tower, tower-http, dashmap Updated version to 1.0.1 in Cargo.toml. --- Cargo.lock | 321 +++++++++++++++++++++++++++++- Cargo.toml | 8 +- src/cli.rs | 11 + src/commands/mod.rs | 1 + src/commands/serve.rs | 453 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 6 files changed, 792 insertions(+), 3 deletions(-) create mode 100644 src/commands/serve.rs diff --git a/Cargo.lock b/Cargo.lock index e50527e..3097f71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "aws-lc-rs" version = "1.16.1" @@ -89,6 +95,58 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -246,6 +304,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -256,6 +320,20 @@ dependencies = [ "typenum", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "digest" version = "0.10.7" @@ -306,12 +384,54 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -359,6 +479,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -380,6 +506,87 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -485,12 +692,24 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.1.1" @@ -546,12 +765,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -698,6 +929,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -753,6 +990,29 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -789,6 +1049,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -828,6 +1094,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "thiserror" version = "2.0.18" @@ -895,12 +1167,55 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1322,11 +1637,13 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotick" -version = "0.1.0" +version = "1.0.1" dependencies = [ + "axum", "base64", "clap", "colored", + "dashmap", "indicatif", "rand", "rustls", @@ -1336,6 +1653,8 @@ dependencies = [ "thiserror", "tokio", "tokio-rustls", + "tower", + "tower-http", "tracing", "tracing-subscriber", "webpki-roots", diff --git a/Cargo.toml b/Cargo.toml index 3a2cddd..2318611 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zerotick" -version = "0.1.0" +version = "1.0.1" edition = "2024" rust-version = "1.87.0" authors = ["Hyphonical"] @@ -41,6 +41,10 @@ base64 = "0.22" sha1 = "0.10" thiserror = "2" rand = "0.10.0" +axum = "0.8.8" +tower-http = "0.6.8" +tower = "0.5.3" +dashmap = "6.1.0" [profile.release.package."*"] opt-level = 3 @@ -60,4 +64,4 @@ codegen-units = 32 incremental = true lto = "off" panic = "abort" -strip = false \ No newline at end of file +strip = false diff --git a/src/cli.rs b/src/cli.rs index 1d0d860..b96bbaf 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -43,6 +43,17 @@ pub enum Command { timeout: u64, }, + /// Start a web server that mimics mcstatus.io API + Serve { + /// Host to bind to + #[arg(long, default_value = "0.0.0.0")] + host: String, + + /// Port to bind to + #[arg(short, long, default_value_t = 8080)] + port: u16, + }, + /// Scan an IP range for Minecraft servers Scan { /// CIDR range (e.g. 192.168.1.0/24) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b2cc105..0e06ea1 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,2 +1,3 @@ pub mod scan; +pub mod serve; pub mod status; diff --git a/src/commands/serve.rs b/src/commands/serve.rs new file mode 100644 index 0000000..73fd5e8 --- /dev/null +++ b/src/commands/serve.rs @@ -0,0 +1,453 @@ +use crate::error::Result; +use crate::net::resolve; +use crate::protocol::{legacy, slp}; +use crate::style; +use crate::types::{ChatComponent, Description}; +use axum::{ + extract::{ConnectInfo, Path}, + http::{StatusCode, HeaderMap}, + response::IntoResponse, + routing::get, + Router, + Json, +}; +use dashmap::DashMap; +use serde::Serialize; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::net::TcpStream; +use tokio::time::timeout; + +// ── mcstatus.io v2 structures ────────────────────── + +#[derive(Debug, Serialize, Clone)] +pub struct McStatusResponse { + pub online: bool, + pub host: String, + pub port: u16, + pub ip_address: Option, + pub eula_blocked: bool, + pub retrieved_at: u64, + pub expires_at: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub players: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub motd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + pub mods: Vec, + pub software: Option, + pub plugins: Vec, + pub srv_record: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct McMod { + pub name: String, + pub version: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct McPlugin { + pub name: String, + pub version: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct McVersion { + pub name_raw: String, + pub name_clean: String, + pub name_html: String, + pub protocol: i32, +} + +#[derive(Debug, Serialize, Clone)] +pub struct McPlayers { + pub online: i32, + pub max: i32, + pub list: Vec, +} + +#[derive(Debug, Serialize, Clone)] +pub struct McPlayerSample { + pub uuid: String, + pub name_raw: String, + pub name_clean: String, + pub name_html: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct McMotd { + pub raw: String, + pub clean: String, + pub html: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct McSrvRecord { + pub host: String, + pub port: u16, +} + +// ── Server State ───────────────────────────────────── + +struct AppState { + cache: DashMap, + rate_limiter: DashMap, +} + +pub async fn run(host: &str, port: u16) -> Result<()> { + let major_version = env!("CARGO_PKG_VERSION") + .split('.') + .next() + .unwrap_or("1"); + let route = format!("/v{major_version}/status/java/{{address}}"); + + let state = Arc::new(AppState { + cache: DashMap::new(), + rate_limiter: DashMap::new(), + }); + + let app = Router::new() + .route(&route, get(handle_java_status)) + .with_state(state); + + let addr = format!("{}:{}", host, port); + let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| { + crate::error::Error::Protocol(format!("failed to bind to {addr}: {e}")) + })?; + + println!("{}", style::success(&format!("ZeroTick serve listening on http://{}", addr))); + + axum::serve(listener, app.into_make_service_with_connect_info::()) + .await + .map_err(|e| crate::error::Error::Protocol(format!("server error: {e}")))?; + + Ok(()) +} + +async fn handle_java_status( + Path(address): Path, + ConnectInfo(client_addr): ConnectInfo, + axum::extract::State(state): axum::extract::State>, +) -> impl IntoResponse { + let start_time = Instant::now(); + + // Rate limiting: 5 requests per second per IP + { + let ip = client_addr.ip(); + let mut entry = state.rate_limiter.entry(SocketAddr::new(ip, 0)).or_insert((0, Instant::now())); + let val = entry.value_mut(); + if val.1.elapsed() > Duration::from_secs(1) { + val.0 = 0; + val.1 = Instant::now(); + } + if val.0 >= 5 { + log_request(&address, client_addr, StatusCode::TOO_MANY_REQUESTS, start_time.elapsed(), false); + return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": "Too many requests" }))).into_response(); + } + val.0 += 1; + } + + // Caching: 30 seconds + if let Some(cached) = state.cache.get(&address) { + let (response, timestamp) = cached.value(); + if timestamp.elapsed() < Duration::from_secs(30) { + log_request(&address, client_addr, StatusCode::OK, start_time.elapsed(), true); + let mut headers = HeaderMap::new(); + headers.insert("X-Cache-Hit", "true".parse().unwrap()); + let remaining = 30u64.saturating_sub(timestamp.elapsed().as_secs()); + headers.insert("X-Cache-Time-Remaining", remaining.to_string().parse().unwrap()); + return (headers, Json(response.clone())).into_response(); + } + } + + // Fetch status + let response = match fetch_status(&address).await { + Ok(resp) => resp, + Err(e) => { + log_request(&address, client_addr, StatusCode::INTERNAL_SERVER_ERROR, start_time.elapsed(), false); + return (StatusCode::INTERNAL_SERVER_ERROR, format!("Error: {e}")).into_response(); + } + }; + + state.cache.insert(address.clone(), (response.clone(), Instant::now())); + + log_request(&address, client_addr, StatusCode::OK, start_time.elapsed(), false); + (StatusCode::OK, Json(response)).into_response() +} + +async fn fetch_status(address: &str) -> Result { + let resolved = resolve::resolve(address).await?; + let connect_timeout = Duration::from_secs(5); + let socket_addr = format!("{}:{}", resolved.host, resolved.port); + + let ip_address = tokio::net::lookup_host(&socket_addr) + .await + .ok() + .and_then(|mut addrs| addrs.next()) + .map(|addr| addr.ip().to_string()); + + let retrieved_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64; + let expires_at = retrieved_at + 30000; + + let srv_record = if resolved.srv { + Some(McSrvRecord { + host: resolved.host.clone(), + port: resolved.port, + }) + } else { + None + }; + + let mut stream = match timeout(connect_timeout, TcpStream::connect(&socket_addr)).await { + Ok(Ok(s)) => s, + _ => { + return Ok(offline_response(resolved.host, resolved.port, ip_address, retrieved_at, expires_at, srv_record)); + } + }; + + let (status, _latency) = match slp::ping(&mut stream, &resolved.host, resolved.port).await { + Ok(res) => res, + Err(_) => { + // Try legacy + let mut stream2 = match timeout(connect_timeout, TcpStream::connect(&socket_addr)).await { + Ok(Ok(s)) => s, + _ => return Ok(offline_response(resolved.host.clone(), resolved.port, ip_address, retrieved_at, expires_at, srv_record)), + }; + match legacy::ping(&mut stream2, &resolved.host, resolved.port).await { + Ok(s) => (s, Duration::ZERO), + Err(_) => return Ok(offline_response(resolved.host, resolved.port, ip_address, retrieved_at, expires_at, srv_record)), + } + } + }; + + let eula_blocked = crate::mojang::blocked::is_blocked(&resolved.host, resolved.port).await.unwrap_or(false); + + Ok(McStatusResponse { + online: true, + host: resolved.host, + port: resolved.port, + ip_address, + eula_blocked, + retrieved_at, + expires_at, + version: Some(McVersion { + name_raw: status.version.name.clone(), + name_clean: clean_motd(&status.version.name), + name_html: motd_to_html(&status.version.name), + protocol: status.version.protocol, + }), + players: Some(McPlayers { + online: status.players.online, + max: status.players.max, + list: status.players.sample.iter().map(|p| McPlayerSample { + uuid: p.id.clone(), + name_raw: p.name.clone(), + name_clean: clean_motd(&p.name), + name_html: motd_to_html(&p.name), + }).collect(), + }), + motd: Some(McMotd { + raw: raw_motd(&status.description), + clean: clean_description(&status.description), + html: description_to_html(&status.description), + }), + icon: status.favicon, + mods: Vec::new(), + software: None, + plugins: Vec::new(), + srv_record, + }) +} + +fn offline_response(host: String, port: u16, ip_address: Option, retrieved_at: u64, expires_at: u64, srv_record: Option) -> McStatusResponse { + McStatusResponse { + online: false, + host, + port, + ip_address, + eula_blocked: false, + retrieved_at, + expires_at, + version: None, + players: None, + motd: None, + icon: None, + mods: Vec::new(), + software: None, + plugins: Vec::new(), + srv_record, + } +} + +// ── MOTD Utilities ─────────────────────────────────── + +fn raw_motd(desc: &Description) -> String { + match desc { + Description::Plain(s) => s.clone(), + Description::Component(c) => component_to_raw(c), + } +} + +fn component_to_raw(comp: &ChatComponent) -> String { + let mut out = comp.text.clone(); + for child in &comp.extra { + out.push_str(&component_to_raw(child)); + } + out +} + +fn clean_description(desc: &Description) -> String { + clean_motd(&raw_motd(desc)) +} + +fn clean_motd(input: &str) -> String { + let mut out = String::new(); + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\u{00A7}' { + chars.next(); + } else { + out.push(ch); + } + } + out +} + +fn description_to_html(desc: &Description) -> String { + match desc { + Description::Plain(s) => motd_to_html(s), + Description::Component(c) => component_to_html(c), + } +} + +fn motd_to_html(input: &str) -> String { + // Minecraft legacy colors to CSS + let mut out = String::from(""); + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\u{00A7}' { + if let Some(code) = chars.next() { + out.push_str(" out.push_str("color: #000000;"), + '1' => out.push_str("color: #0000AA;"), + '2' => out.push_str("color: #00AA00;"), + '3' => out.push_str("color: #00AAAA;"), + '4' => out.push_str("color: #AA0000;"), + '5' => out.push_str("color: #AA00AA;"), + '6' => out.push_str("color: #FFAA00;"), + '7' => out.push_str("color: #AAAAAA;"), + '8' => out.push_str("color: #555555;"), + '9' => out.push_str("color: #5555FF;"), + 'a' => out.push_str("color: #55FF55;"), + 'b' => out.push_str("color: #55FFFF;"), + 'c' => out.push_str("color: #FF5555;"), + 'd' => out.push_str("color: #FF55FF;"), + 'e' => out.push_str("color: #FFFF55;"), + 'f' => out.push_str("color: #FFFFFF;"), + 'l' => out.push_str("font-weight: bold;"), + 'm' => out.push_str("text-decoration: line-through;"), + 'n' => out.push_str("text-decoration: underline;"), + 'o' => out.push_str("font-style: italic;"), + 'r' => {} // Reset + _ => {} + } + out.push_str("\">"); + } + } else { + match ch { + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '&' => out.push_str("&"), + '\n' => out.push_str("
"), + _ => out.push(ch), + } + } + } + out.push_str("
"); + out +} + +fn component_to_html(comp: &ChatComponent) -> String { + let mut style = String::new(); + if let Some(ref color) = comp.color { + let css_color = if color.starts_with('#') { + color.clone() + } else { + match color.as_str() { + "black" => "#000000".to_string(), + "dark_blue" => "#0000AA".to_string(), + "dark_green" => "#00AA00".to_string(), + "dark_aqua" => "#00AAAA".to_string(), + "dark_red" => "#AA0000".to_string(), + "dark_purple" => "#AA00AA".to_string(), + "gold" => "#FFAA00".to_string(), + "gray" => "#AAAAAA".to_string(), + "dark_gray" => "#555555".to_string(), + "blue" => "#5555FF".to_string(), + "green" => "#55FF55".to_string(), + "aqua" => "#55FFFF".to_string(), + "red" => "#FF5555".to_string(), + "light_purple" => "#FF55FF".to_string(), + "yellow" => "#FFFF55".to_string(), + "white" => "#FFFFFF".to_string(), + _ => color.clone(), + } + }; + style.push_str(&format!("color: {};", css_color)); + } + if comp.bold == Some(true) { + style.push_str("font-weight: bold;"); + } + if comp.italic == Some(true) { + style.push_str("font-style: italic;"); + } + if comp.underlined == Some(true) { + style.push_str("text-decoration: underline;"); + } + if comp.strikethrough == Some(true) { + style.push_str("text-decoration: line-through;"); + } + + let mut out = format!("{}", style, comp.text); + for child in &comp.extra { + out.push_str(&component_to_html(child)); + } + out.push_str(""); + out +} + +// ── Logging ────────────────────────────────────────── + +fn log_request(address: &str, client_addr: SocketAddr, status: StatusCode, duration: Duration, cached: bool) { + use colored::Colorize; + let status_str = if status.is_success() { + status.as_str().green() + } else if status.is_client_error() { + status.as_str().yellow() + } else { + status.as_str().red() + }; + + let cache_str = if cached { + "[CACHE]".cyan() + } else { + "[MISS]".dimmed() + }; + + println!( + "{} {} {} {} {} {}", + style::BULLET.cyan(), + client_addr.to_string().dimmed(), + "GET".bold(), + address.color(style::ACCENT), + status_str, + format!("{:?}", duration).dimmed(), + ); + println!(" {} {}", style::DOT.dimmed(), cache_str); +} diff --git a/src/main.rs b/src/main.rs index 4495756..7d70222 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ async fn main() { let result = match cli.command { cli::Command::Status { address, timeout } => commands::status::run(&address, timeout).await, + cli::Command::Serve { host, port } => commands::serve::run(&host, port).await, cli::Command::Scan { range, port, From 4fffb2f7c70fb4f406a7ccbc407c29bcde2c93bf Mon Sep 17 00:00:00 2001 From: Hyphonical <69310714+Hyphonical@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:53:58 +0000 Subject: [PATCH 2/2] Add `zerotick serve` command Implemented a new `serve` command that provides a Minecraft server status API mirroring the `mcstatus.io` v2 JSON response format. Key features: - API endpoint: `GET /v{major}/status/java/:address` (major version from Cargo.toml) - In-memory results caching (30 seconds) - Per-IP rate limiting (5 req/sec) - Styled request logging in console - Robust MOTD to HTML conversion with Minecraft color codes and style accumulation - Memory safety: Background cleanup task for cache and rate limiter state - CI compatibility: Architecture-specific `target-cpu` optimization Updated version to 1.0.1 in Cargo.toml. --- .cargo/config.toml | 13 +++--- Cargo.toml | 8 ++-- src/commands/serve.rs | 97 ++++++++++++++++++++++++++++++++----------- 3 files changed, 83 insertions(+), 35 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 9eefdbc..f564d89 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,7 +1,8 @@ -[build] -# Enable AVX2 auto-vectorization on all CPU scalar loops. -# x86-64-v3 baseline covers ~95% of modern x86-64 systems and includes: -# - SSE, SSE2, SSE3, SSSE3, SSE4.1, SSE4.2 -# - AVX, AVX2 -# For broader compatibility (older chips), use "x86-64-v2" instead. +[target.'cfg(all(target_arch = "x86_64", target_os = "linux"))'] +rustflags = ["-C", "target-cpu=x86-64-v3"] + +[target.'cfg(all(target_arch = "x86_64", target_os = "windows"))'] +rustflags = ["-C", "target-cpu=x86-64-v3"] + +[target.'cfg(all(target_arch = "x86_64", target_os = "macos"))'] rustflags = ["-C", "target-cpu=x86-64-v3"] diff --git a/Cargo.toml b/Cargo.toml index 2318611..63723c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,10 +41,10 @@ base64 = "0.22" sha1 = "0.10" thiserror = "2" rand = "0.10.0" -axum = "0.8.8" -tower-http = "0.6.8" -tower = "0.5.3" -dashmap = "6.1.0" +axum = { version = "0.8", features = ["json"] } +tower-http = "0.6" +tower = "0.5" +dashmap = "6.1" [profile.release.package."*"] opt-level = 3 diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 73fd5e8..7646459 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -111,8 +111,20 @@ pub async fn run(host: &str, port: u16) -> Result<()> { rate_limiter: DashMap::new(), }); + let state_for_cleanup = state.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + // Simple cleanup: clear everything every minute to prevent unbounded growth. + // In a real production app, we'd use a TTL cache. + state_for_cleanup.cache.clear(); + state_for_cleanup.rate_limiter.clear(); + tracing::debug!("ZeroTick serve: cleared cache and rate limiter"); + } + }); + let app = Router::new() - .route(&route, get(handle_java_status)) + .route(&route.replace("{address}", ":address"), get(handle_java_status)) .with_state(state); let addr = format!("{}:{}", host, port); @@ -326,37 +338,65 @@ fn description_to_html(desc: &Description) -> String { } fn motd_to_html(input: &str) -> String { - // Minecraft legacy colors to CSS let mut out = String::from(""); let mut chars = input.chars().peekable(); + + let mut color: Option<&str> = None; + let mut bold = false; + let mut strike = false; + let mut underline = false; + let mut italic = false; + while let Some(ch) = chars.next() { if ch == '\u{00A7}' { if let Some(code) = chars.next() { - out.push_str(" out.push_str("color: #000000;"), - '1' => out.push_str("color: #0000AA;"), - '2' => out.push_str("color: #00AA00;"), - '3' => out.push_str("color: #00AAAA;"), - '4' => out.push_str("color: #AA0000;"), - '5' => out.push_str("color: #AA00AA;"), - '6' => out.push_str("color: #FFAA00;"), - '7' => out.push_str("color: #AAAAAA;"), - '8' => out.push_str("color: #555555;"), - '9' => out.push_str("color: #5555FF;"), - 'a' => out.push_str("color: #55FF55;"), - 'b' => out.push_str("color: #55FFFF;"), - 'c' => out.push_str("color: #FF5555;"), - 'd' => out.push_str("color: #FF55FF;"), - 'e' => out.push_str("color: #FFFF55;"), - 'f' => out.push_str("color: #FFFFFF;"), - 'l' => out.push_str("font-weight: bold;"), - 'm' => out.push_str("text-decoration: line-through;"), - 'n' => out.push_str("text-decoration: underline;"), - 'o' => out.push_str("font-style: italic;"), - 'r' => {} // Reset + '0' => color = Some("#000000"), + '1' => color = Some("#0000AA"), + '2' => color = Some("#00AA00"), + '3' => color = Some("#00AAAA"), + '4' => color = Some("#AA0000"), + '5' => color = Some("#AA00AA"), + '6' => color = Some("#FFAA00"), + '7' => color = Some("#AAAAAA"), + '8' => color = Some("#555555"), + '9' => color = Some("#5555FF"), + 'a' => color = Some("#55FF55"), + 'b' => color = Some("#55FFFF"), + 'c' => color = Some("#FF5555"), + 'd' => color = Some("#FF55FF"), + 'e' => color = Some("#FFFF55"), + 'f' => color = Some("#FFFFFF"), + 'l' => bold = true, + 'm' => strike = true, + 'n' => underline = true, + 'o' => italic = true, + 'r' => { + color = None; + bold = false; + strike = false; + underline = false; + italic = false; + } _ => {} } + + out.push_str(""); } } else { @@ -414,7 +454,14 @@ fn component_to_html(comp: &ChatComponent) -> String { style.push_str("text-decoration: line-through;"); } - let mut out = format!("{}", style, comp.text); + let escaped_text = comp + .text + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('\n', "
"); + + let mut out = format!("{}", style, escaped_text); for child in &comp.extra { out.push_str(&component_to_html(child)); }