diff --git a/Cargo.lock b/Cargo.lock index db3b51c..f4ee98a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,7 +208,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-link", ] @@ -686,8 +688,9 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hlstate-httpd" -version = "0.1.0" +version = "0.2.0" dependencies = [ + "chrono", "clap", "rocket", "rocket_dyn_templates", diff --git a/crates/httpd/Cargo.toml b/crates/httpd/Cargo.toml index c67a3ec..0f4b76f 100644 --- a/crates/httpd/Cargo.toml +++ b/crates/httpd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hlstate-httpd" -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "MIT" readme = "README.md" @@ -10,6 +10,7 @@ categories = ["parsing", "text-processing", "value-formatting"] repository = "https://github.com/YGGverse/hlstate-rs" [dependencies] +chrono = "0.4.44" #observer = { git = "https://github.com/YGGverse/xash3d-master.git", package = "xash3d-observer", branch = "ip6-only" } #protocol = { git = "https://github.com/YGGverse/xash3d-master.git", package = "xash3d-protocol", branch = "ip6-only" } #observer = { path = "../../../xash3d-master/observer", package = "xash3d-observer" } @@ -18,4 +19,4 @@ clap = { version = "4.5.54", features = ["derive"] } #mysql = { package = "hlstate-mysql", version = "0.1.0", path = "../mysql" } rocket = { version = "0.5.1", features = ["json"] } rocket_dyn_templates = { version = "0.2.0", features = ["tera"] } -toml = "0.9.10" \ No newline at end of file +toml = "0.9.10" diff --git a/crates/httpd/config.toml b/crates/httpd/config.toml index afef9f8..33b32b6 100644 --- a/crates/httpd/config.toml +++ b/crates/httpd/config.toml @@ -12,5 +12,11 @@ debug = true # Path to `xash3d-query` bin query = "xash3d-query" +# Interval to update master scrape, in seconds +refresh = 60 + # Define at least one master to scrape game servers from -masters = ["[202:68d0:f0d5:b88d:1d1a:555e:2f6b:3148]:27010", "[300:dee4:d3c0:953b::2019]:27010"] \ No newline at end of file +masters = [ + "[202:68d0:f0d5:b88d:1d1a:555e:2f6b:3148]:27010", + "[300:dee4:d3c0:953b::2019]:27010" +] \ No newline at end of file diff --git a/crates/httpd/src/config.rs b/crates/httpd/src/config.rs index 189f57e..9f7932c 100644 --- a/crates/httpd/src/config.rs +++ b/crates/httpd/src/config.rs @@ -14,4 +14,5 @@ pub struct Config { pub port: u16, pub query: PathBuf, pub title: String, + pub refresh: u64, } diff --git a/crates/httpd/src/feed.rs b/crates/httpd/src/feed.rs new file mode 100644 index 0000000..661da02 --- /dev/null +++ b/crates/httpd/src/feed.rs @@ -0,0 +1,69 @@ +use chrono::{DateTime, Utc}; +use std::net::SocketAddr; + +/// Export crawl results as the RSS +pub struct Feed(String); + +impl Feed { + pub fn new(title: &str, description: Option<&str>) -> Self { + let t = chrono::Utc::now().to_rfc2822(); + let mut b = String::new(); + + b.push_str(""); + + b.push_str(""); + b.push_str(&t); + b.push_str(""); + + b.push_str(""); + b.push_str(&t); + b.push_str(""); + + b.push_str(""); + b.push_str(title); + b.push_str(""); + + if let Some(d) = description { + b.push_str(""); + b.push_str(d); + b.push_str("") + } + + Self(b) + } + + /// Appends `item` to the feed `channel` + pub fn push( + &mut self, + time: &DateTime, + address: &SocketAddr, + host: &String, + map: &String, + online: u32, + ) { + let a = address.to_string(); // allocate once + + self.0.push_str(&format!( + "{}{host}udp://{a}", + time.timestamp() // must be unique as the event + )); + + self.0.push_str(""); + self.0.push_str(&format!("connect: {a}\n")); + self.0.push_str(&format!("map: {map}\n")); + self.0.push_str(&format!("online: {online}\n")); + self.0.push_str(""); + + self.0.push_str(""); + self.0.push_str(&time.to_rfc2822()); + self.0.push_str(""); + + self.0.push_str("") + } + + /// Write final bytes + pub fn commit(mut self) -> String { + self.0.push_str(""); + self.0 + } +} diff --git a/crates/httpd/src/main.rs b/crates/httpd/src/main.rs index ac07dbc..2ee44d5 100644 --- a/crates/httpd/src/main.rs +++ b/crates/httpd/src/main.rs @@ -3,68 +3,135 @@ extern crate rocket; mod argument; mod config; +mod feed; mod global; mod meta; mod scrape; +use chrono::{DateTime, Utc}; +use feed::Feed; use global::Global; use meta::Meta; -use rocket::{State, http::Status}; +use rocket::{ + State, + form::FromForm, + http::Status, + response::content::RawXml, + tokio::{spawn, sync::RwLock, time::sleep}, +}; use rocket_dyn_templates::{Template, context}; +use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; + +struct Online { + /// Actual state container + current: scrape::Result, + /// Hold previous `current` state updated to compare and notify subscribers for online change + last: Option, + /// Snap time + update: DateTime, +} +type Snap = Arc>>; #[get("/")] -fn index(meta: &State, global: &State) -> Result { - // @TODO: requires library impl - // https://github.com/FWGS/xash3d-master/issues/4 - let scrape = std::process::Command::new(&global.query) - .arg("all") - .arg("-M") - .arg( - global - .masters - .iter() - .map(|a| a.to_string()) - .collect::>() - .join(","), - ) - .arg("-j") - .output() - .map_err(|e| { - error!("Make sure `xash3d-query` is installed: {e}"); - Status::InternalServerError - })?; - if scrape.status.success() { - let result: scrape::Result = rocket::serde::json::serde_json::from_str( - str::from_utf8(&scrape.stdout).map_err(|e| { - error!("stdout parse error: {e}"); - Status::InternalServerError - })?, - ) - .map_err(|e| { - error!("JSON parse error: {e}"); - Status::InternalServerError - })?; - Ok(Template::render( - "index", - context! { - masters: &global.masters, - title: &meta.title, - version: &meta.version, - servers: result.servers, - }, - )) - } else { - error!("Make sure `xash3d-query` is installed!"); - Err(Status::InternalServerError) +async fn index( + meta: &State, + global: &State, + online: &State, +) -> Result { + let snap = online.read().await; + Ok(Template::render( + "index", + context! { + masters: &global.masters, + title: &meta.title, + version: &meta.version, + servers: snap.as_ref().map(|s|s.current.servers.clone()), + updated: snap.as_ref().map(|s|s.update.to_rfc2822()) + }, + )) +} + +#[derive(FromForm, Debug)] +struct RssParams { + online: Option, + servers: Option>, +} +#[get("/rss?")] +async fn rss( + params: RssParams, + meta: &State, + online: &State, +) -> Result, Status> { + let mut f = Feed::new( + &meta.title, + None, // @TODO service description + ); + if let Some(state) = online.read().await.as_ref() { + for current in &state.current.servers { + if state.last.as_ref().is_none_or(|l| { + l.servers + .iter() + .any(|last| current.address == last.address && current.numcl > last.numcl) + }) && params.online.is_none_or(|online| current.numcl >= online) + && params + .servers + .as_ref() + .is_none_or(|servers| servers.iter().any(|address| address == ¤t.address)) + { + f.push( + &state.update, + ¤t.address, + ¤t.host, + ¤t.map, + current.numcl, + ) + } + } } + Ok(RawXml(f.commit())) } #[launch] -fn rocket() -> _ { +async fn rocket() -> _ { use clap::Parser; let argument = argument::Argument::parse(); let config: config::Config = toml::from_str(&std::fs::read_to_string(argument.config).unwrap()).unwrap(); + let online: Snap = Arc::new(RwLock::new(None)); + spawn({ + let online = online.clone(); + let query = config.query.clone(); + let masters = config.masters.clone(); + async move { + loop { + match scrape(&query, &masters) { + Ok(s) => match str::from_utf8(&s.stdout) { + Ok(r) => { + if s.status.success() { + let mut state = online.write().await; + *state = match rocket::serde::json::serde_json::from_str(r) { + Ok(current) => Some(Online { + current, + last: state.as_ref().map(|last| last.current.clone()), + update: Utc::now(), + }), + Err(e) => { + error!("Could not decode scrape response: `{e}`"); + None + } + } + } else { + error!("Scrape request failed"); + } + } + Err(e) => error!("Could not decode UTF-8: `{e}`"), + }, + Err(e) => error!("Scrape error: `{e}`"), + } + sleep(Duration::from_secs(config.refresh)).await; + } + } + }); rocket::build() .attach(Template::fairing()) .configure(rocket::Config { @@ -76,6 +143,7 @@ fn rocket() -> _ { rocket::Config::release_default() } }) + .manage(online) .manage(Global { masters: config.masters, query: config.query, @@ -84,5 +152,26 @@ fn rocket() -> _ { title: config.title, version: env!("CARGO_PKG_VERSION").into(), }) - .mount("/", routes![index]) + .mount("/", routes![index, rss]) +} + +/// Get servers online using `bin` from given `masters` +fn scrape( + bin: &PathBuf, + masters: &HashSet, +) -> Result { + // @TODO: requires library impl + // https://github.com/FWGS/xash3d-master/issues/4 + std::process::Command::new(bin) + .arg("all") + .arg("-M") + .arg( + masters + .iter() + .map(|a| a.to_string()) + .collect::>() + .join(","), + ) + .arg("-j") + .output() } diff --git a/crates/httpd/src/scrape.rs b/crates/httpd/src/scrape.rs index 8fd0624..336cb43 100644 --- a/crates/httpd/src/scrape.rs +++ b/crates/httpd/src/scrape.rs @@ -1,7 +1,7 @@ use rocket::serde::{Deserialize, Serialize}; use std::net::SocketAddr; -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Clone)] #[serde(crate = "rocket::serde")] pub struct Result { pub protocol: Vec, @@ -12,7 +12,7 @@ pub struct Result { pub servers: Vec, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Clone)] #[serde(crate = "rocket::serde")] pub struct Info { pub time: i64, diff --git a/crates/httpd/templates/index.html.tera b/crates/httpd/templates/index.html.tera index 4a9984d..3c1f0e3 100644 --- a/crates/httpd/templates/index.html.tera +++ b/crates/httpd/templates/index.html.tera @@ -2,46 +2,56 @@ {% block content %}

Game

{% if servers %} - - - - - - - - - - - - - - - - - - - - - {% for server in servers %} - - - - - - - - - - - - - - - - - {% endfor %} - -
AddressHostPingProtocolGamedirMapTeamCoopPasswordDedicatedDMMaxOnlineStatus
{{ server.address }}{{ server.host }}{{ server.ping }}{{ server.protocol }}{{ server.gamedir }}{{ server.map }}{{ server.team }}{{ server.coop }}{{ server.password }}{{ server.dedicated }}{{ server.dm }}{{ server.maxcl }}{{ server.numcl }}{{ server.status }}
+
+ + + + + + + + + + + + + + + + + + + + + + {% for server in servers %} + + + + + + + + + + + + + + + + + + {% endfor %} + +
AddressHostPingProtocolGamedirMapTeamCoopPasswordDedicatedDMMaxOnlineStatus
{{ server.address }}{{ server.host }}{{ server.ping }}{{ server.protocol }}{{ server.gamedir }}{{ server.map }}{{ server.team }}{{ server.coop }}{{ server.password }}{{ server.dedicated }}{{ server.dm }}{{ server.maxcl }}{{ server.numcl }}{{ server.status }}
+
+ + +
+
{% else %}

Nobody.

diff --git a/crates/httpd/templates/layout.html.tera b/crates/httpd/templates/layout.html.tera index 89345c6..26f3c43 100644 --- a/crates/httpd/templates/layout.html.tera +++ b/crates/httpd/templates/layout.html.tera @@ -9,7 +9,7 @@ padding: 0; font-family: monospace; color-scheme: light dark; - --container-max-width: 1024px; + --container-max-width: 1128px; --color-success: #4bc432; --color-warning: #f37b21; --color-error: #ff6363; @@ -75,12 +75,39 @@ position: relative; } + header > div { + display: inline-block; + } + + header > div:first-child { + float: left; + } + + header > div:last-child { + float: right; + text-align: right; + } + main { display: block; margin: 16px auto; max-width: var(--container-max-width); } + main > form > fieldset { + border: 0; + margin: 16px 0; + } + + main > form > fieldset input { + padding: 0 4px; + } + + main > form > fieldset input[type="number"] { + margin: 0 4px; + max-width: 36px; + } + footer { display: block; margin: 16px auto; @@ -101,10 +128,7 @@

-
- -
-
+
{{ title }} | Game | @@ -113,18 +137,21 @@ GitHub
+
+ {% if updated %}{{ updated }}{% endif %} +
{% block content %}{% endblock content %}