Fetching minecraft server status

This commit is contained in:
Daniel Flanagan 2024-04-11 12:59:34 -05:00
parent 62d902550e
commit 4226f7c143
9 changed files with 1467 additions and 46 deletions

View file

@ -1,2 +1,5 @@
[env] [env]
RUST_BACKTRACE = "1" RUST_BACKTRACE = "1"
# [profile.release]
# RUSTFLAGS="-Zlocation-detail=none"

1205
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,12 +5,22 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0.82" anyhow = "1.0.82"
axum = "0.7.5"
clap = "4.5.4" clap = "4.5.4"
color-eyre = "0.6.3" color-eyre = "0.6.3"
discord = { git = "https://github.com/SpaceManiac/discord-rs" } discord = { git = "https://github.com/SpaceManiac/discord-rs" }
tokio = "1.37.0" redact = { version = "0.1.9", features = ["serde"] }
reqwest = { version = "0.12.3", features = ["json", "socks"] }
reqwest-middleware = "0.3.0"
reqwest-retry = "0.5.0"
reqwest-tracing = "0.5.0"
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0.115"
serde_with = "3.7.0"
tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.40" tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
urlencoding = "2.1.3"
[profile.dev] [profile.dev]
opt-level = 1 opt-level = 1

View file

@ -1,33 +1,31 @@
mod config; #![forbid(unsafe_code)]
mod prelude;
use tracing::level_filters::LevelFilter; mod config;
use tracing_subscriber::EnvFilter; mod minecraft_server_status;
mod observe;
mod prelude;
mod webclient;
mod webserver;
use crate::prelude::*; use crate::prelude::*;
fn main() { #[tokio::main]
async fn main() {
let (conf, conf_err) = Config::load_or_defaults(); let (conf, conf_err) = Config::load_or_defaults();
observe::setup_logging(&conf);
color_eyre::install().expect("Failed to install color_eyre");
let filter = EnvFilter::builder()
.with_default_directive(LevelFilter::TRACE.into())
.parse_lossy("info,chatbot=trace");
tracing_subscriber::fmt().with_env_filter(filter).init();
debug!("Configuration: {conf:?}"); debug!("Configuration: {conf:?}");
if let Some(err) = conf_err { if let Some(err) = conf_err {
warn!("Error loading configuration: {err}"); warn!("Error loading configuration: {err}");
} }
// TODO: tracing stuff
// TODO: load config
// TODO: oh yeah we need an HTTP server to handle minecraft server status stuff // TODO: oh yeah we need an HTTP server to handle minecraft server status stuff
let server = webserver::start(&conf);
// TODO: family reminders? // TODO: family reminders?
// TODO: connect to Discord // TODO: connect to Discord
// TODO: handle messages // TODO: handle messages
// TODO: data persistence? (sqlite? sled?) // TODO: data persistence? (sqlite? sled?)
info!("Hello, world!");
server.await
} }

View file

@ -0,0 +1,109 @@
use crate::prelude::*;
use crate::webclient::Client;
use serde_with::skip_serializing_none;
use reqwest::header::{HeaderMap, HeaderValue};
use std::collections::HashMap;
pub struct MinecraftServerStatus {
client: Client,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Player {
pub name: String,
pub uuid: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct PlayerInfo {
pub max: u32,
pub online: u32,
#[serde(skip_serializing)]
pub list: Option<Vec<Player>>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Plugin {
pub name: String,
pub version: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Protocol {
pub name: String,
pub version: u64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DebugInfo {
pub ping: bool,
pub query: bool,
pub srv: bool,
pub querymismatch: bool,
pub ipinsrv: bool,
pub cnameinsrv: bool,
pub animatedmotd: bool,
pub cachehit: bool,
pub cachetime: u64,
pub cacheexpire: u64,
pub apiversion: u32,
}
#[skip_serializing_none]
#[derive(Serialize, Deserialize, Debug)]
pub struct ServerStatus {
pub online: bool,
#[serde(skip_serializing)]
pub ip: Option<String>,
#[serde(skip_serializing)]
pub port: Option<u16>,
#[serde(skip_serializing)]
pub hostname: Option<String>,
#[serde(skip_serializing)]
pub debug: Option<DebugInfo>,
pub version: Option<String>,
pub protocol: Option<Protocol>,
pub icon: Option<String>,
pub gamemode: Option<String>,
pub serverid: Option<String>,
pub eula_blocked: Option<bool>,
pub map: Option<HashMap<String, String>>,
pub motd: Option<HashMap<String, Vec<String>>>,
pub players: Option<PlayerInfo>,
#[serde(skip_serializing)]
pub plugins: Option<Vec<Plugin>>,
#[serde(skip_serializing)]
pub mods: Option<Vec<Plugin>>,
#[serde(skip_serializing)]
pub software: Option<String>,
#[serde(skip_serializing)]
pub info: Option<HashMap<String, Vec<String>>>,
}
impl MinecraftServerStatus {
pub fn try_new() -> Result<Self> {
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_str("application/json")?);
headers.insert("accepts", HeaderValue::from_str("application/json")?);
let base_client = Client::builder().default_headers(headers).build()?;
let client = Client::try_new(base_client, "https://api.mcsrvstat.us/3")?;
Ok(Self { client })
}
pub async fn status(&self, endpoint: &str) -> Result<ServerStatus> {
self.client
.get(&format!("/{}", urlencoding::encode(endpoint)))
.await
}
}

14
src/observe.rs Normal file
View file

@ -0,0 +1,14 @@
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;
use crate::prelude::*;
pub fn setup_logging(_conf: &Config) {
color_eyre::install().expect("Failed to install color_eyre");
let filter = EnvFilter::builder()
.with_default_directive(LevelFilter::TRACE.into())
.parse_lossy("trace,chatbot=trace");
tracing_subscriber::fmt().with_env_filter(filter).init();
}

View file

@ -3,4 +3,7 @@
// pub use color_eyre; // pub use color_eyre;
pub use crate::config::Config; pub use crate::config::Config;
pub use anyhow::Error; pub use anyhow::Error;
pub use anyhow::Result;
pub use redact::Secret;
pub use serde::{Deserialize, Serialize};
pub use tracing::{debug, error, info, trace, warn}; pub use tracing::{debug, error, info, trace, warn};

70
src/webclient.rs Normal file
View file

@ -0,0 +1,70 @@
use crate::prelude::*;
use reqwest::{Client as RClient, Method, Url};
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware, RequestBuilder};
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
use reqwest_tracing::TracingMiddleware;
use serde::de;
pub struct Client {
client: ClientWithMiddleware,
base_url: Url,
}
pub trait ResourceRequest {
async fn res<T: de::DeserializeOwned>(self) -> Result<T>;
}
impl ResourceRequest for RequestBuilder {
async fn res<T: de::DeserializeOwned>(self) -> Result<T> {
let body = self.send().await?.text().await?;
debug!(body);
serde_json::from_str(&body).map_err(|e| e.into())
// self.send().await?.json().await.map_err(|e| e.into())
}
}
impl Client {
pub fn builder() -> reqwest::ClientBuilder {
RClient::builder()
}
pub fn try_new(client: RClient, base_url: &str) -> Result<Self> {
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
let client = ClientBuilder::new(client)
.with(TracingMiddleware::default())
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build();
// force trailing slash
let mut base_url = Url::parse(base_url)?;
base_url.set_path(&format!("{}{}", base_url.path().trim_end_matches('/'), "/"));
Ok(Self { client, base_url })
}
/// Takes a path segment and properly joins it relative to `base_url`
pub fn url(&self, path: &str) -> Result<Url> {
self.base_url
.join(path.trim_start_matches('/'))
.map_err(|e| e.into())
}
pub fn build(&self, method: Method, rel_url_path: &str) -> Result<RequestBuilder> {
Ok(self.client.request(method, self.url(rel_url_path)?))
}
pub async fn request<T: de::DeserializeOwned>(
&self,
method: Method,
rel_url_path: &str,
) -> Result<T> {
self.build(method, rel_url_path)?.res().await
}
pub async fn get<T: de::DeserializeOwned>(&self, rel_url_path: &str) -> Result<T> {
self.request::<T>(Method::GET, rel_url_path).await
}
}

67
src/webserver.rs Normal file
View file

@ -0,0 +1,67 @@
use std::sync::Arc;
use axum::{
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use reqwest::StatusCode;
use crate::{minecraft_server_status::MinecraftServerStatus, prelude::*};
struct WebserverError(anyhow::Error);
type Result<T> = std::result::Result<T, WebserverError>;
pub async fn start(_conf: &Config) {
let app = Router::new()
.route("/", get(hello_world))
.route("/minecraft-server-status", get(minecraft_server_status));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
info!("Listening on {:?}", listener);
axum::serve(listener, app).await.unwrap();
}
async fn hello_world() -> impl IntoResponse {
"Hello, World!".to_owned()
}
async fn minecraft_server_status() -> Result<impl IntoResponse> {
let c = Arc::new(MinecraftServerStatus::try_new()?);
let reqs: Vec<&str> = vec!["h.lyte.dev:26965", "ourcraft.lyte.dev"];
let mut futures = Vec::with_capacity(reqs.len());
let mut results = Vec::with_capacity(reqs.len());
for r in reqs {
let client = c.clone();
debug!("Fetching server status for jql {}", r);
futures.push(tokio::spawn(async move { client.status(r).await }));
}
for task in futures {
results.push(task.await??)
}
Ok(Json(results))
}
impl IntoResponse for WebserverError {
fn into_response(self) -> Response {
error!("WebserverError: {:?}", self.0);
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", self.0),
)
.into_response()
}
}
// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into
// `Result<_, AppError>`. That way you don't need to do that manually.
impl<E> From<E> for WebserverError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}