Merge pull request #2 from YGGverse/subscribe-game-event-rss

Subscribe game event rss
This commit is contained in:
oooo-ps 2026-03-27 06:38:05 +02:00 committed by GitHub
commit e1d9e8cf21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 305 additions and 99 deletions

5
Cargo.lock generated
View file

@ -208,7 +208,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys",
"num-traits", "num-traits",
"wasm-bindgen",
"windows-link", "windows-link",
] ]
@ -686,8 +688,9 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]] [[package]]
name = "hlstate-httpd" name = "hlstate-httpd"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"chrono",
"clap", "clap",
"rocket", "rocket",
"rocket_dyn_templates", "rocket_dyn_templates",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "hlstate-httpd" name = "hlstate-httpd"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
readme = "README.md" readme = "README.md"
@ -10,6 +10,7 @@ categories = ["parsing", "text-processing", "value-formatting"]
repository = "https://github.com/YGGverse/hlstate-rs" repository = "https://github.com/YGGverse/hlstate-rs"
[dependencies] [dependencies]
chrono = "0.4.44"
#observer = { git = "https://github.com/YGGverse/xash3d-master.git", package = "xash3d-observer", branch = "ip6-only" } #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" } #protocol = { git = "https://github.com/YGGverse/xash3d-master.git", package = "xash3d-protocol", branch = "ip6-only" }
#observer = { path = "../../../xash3d-master/observer", package = "xash3d-observer" } #observer = { path = "../../../xash3d-master/observer", package = "xash3d-observer" }

View file

@ -12,5 +12,11 @@ debug = true
# Path to `xash3d-query` bin # Path to `xash3d-query` bin
query = "xash3d-query" query = "xash3d-query"
# Interval to update master scrape, in seconds
refresh = 60
# Define at least one master to scrape game servers from # 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"] masters = [
"[202:68d0:f0d5:b88d:1d1a:555e:2f6b:3148]:27010",
"[300:dee4:d3c0:953b::2019]:27010"
]

View file

@ -14,4 +14,5 @@ pub struct Config {
pub port: u16, pub port: u16,
pub query: PathBuf, pub query: PathBuf,
pub title: String, pub title: String,
pub refresh: u64,
} }

69
crates/httpd/src/feed.rs Normal file
View file

@ -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("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\"><channel>");
b.push_str("<pubDate>");
b.push_str(&t);
b.push_str("</pubDate>");
b.push_str("<lastBuildDate>");
b.push_str(&t);
b.push_str("</lastBuildDate>");
b.push_str("<title>");
b.push_str(title);
b.push_str("</title>");
if let Some(d) = description {
b.push_str("<description>");
b.push_str(d);
b.push_str("</description>")
}
Self(b)
}
/// Appends `item` to the feed `channel`
pub fn push(
&mut self,
time: &DateTime<Utc>,
address: &SocketAddr,
host: &String,
map: &String,
online: u32,
) {
let a = address.to_string(); // allocate once
self.0.push_str(&format!(
"<item><guid>{}</guid><title>{host}</title><link>udp://{a}</link>",
time.timestamp() // must be unique as the event
));
self.0.push_str("<description>");
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("</description>");
self.0.push_str("<pubDate>");
self.0.push_str(&time.to_rfc2822());
self.0.push_str("</pubDate>");
self.0.push_str("</item>")
}
/// Write final bytes
pub fn commit(mut self) -> String {
self.0.push_str("</channel></rss>");
self.0
}
}

View file

@ -3,68 +3,135 @@ extern crate rocket;
mod argument; mod argument;
mod config; mod config;
mod feed;
mod global; mod global;
mod meta; mod meta;
mod scrape; mod scrape;
use chrono::{DateTime, Utc};
use feed::Feed;
use global::Global; use global::Global;
use meta::Meta; 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 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<scrape::Result>,
/// Snap time
update: DateTime<Utc>,
}
type Snap = Arc<RwLock<Option<Online>>>;
#[get("/")] #[get("/")]
fn index(meta: &State<Meta>, global: &State<Global>) -> Result<Template, Status> { async fn index(
// @TODO: requires library impl meta: &State<Meta>,
// https://github.com/FWGS/xash3d-master/issues/4 global: &State<Global>,
let scrape = std::process::Command::new(&global.query) online: &State<Snap>,
.arg("all") ) -> Result<Template, Status> {
.arg("-M") let snap = online.read().await;
.arg( Ok(Template::render(
global "index",
.masters context! {
.iter() masters: &global.masters,
.map(|a| a.to_string()) title: &meta.title,
.collect::<Vec<_>>() version: &meta.version,
.join(","), servers: snap.as_ref().map(|s|s.current.servers.clone()),
) updated: snap.as_ref().map(|s|s.update.to_rfc2822())
.arg("-j") },
.output() ))
.map_err(|e| { }
error!("Make sure `xash3d-query` is installed: {e}");
Status::InternalServerError #[derive(FromForm, Debug)]
})?; struct RssParams {
if scrape.status.success() { online: Option<u32>,
let result: scrape::Result = rocket::serde::json::serde_json::from_str( servers: Option<Vec<SocketAddr>>,
str::from_utf8(&scrape.stdout).map_err(|e| { }
error!("stdout parse error: {e}"); #[get("/rss?<params..>")]
Status::InternalServerError async fn rss(
})?, params: RssParams,
) meta: &State<Meta>,
.map_err(|e| { online: &State<Snap>,
error!("JSON parse error: {e}"); ) -> Result<RawXml<String>, Status> {
Status::InternalServerError let mut f = Feed::new(
})?; &meta.title,
Ok(Template::render( None, // @TODO service description
"index", );
context! { if let Some(state) = online.read().await.as_ref() {
masters: &global.masters, for current in &state.current.servers {
title: &meta.title, if state.last.as_ref().is_none_or(|l| {
version: &meta.version, l.servers
servers: result.servers, .iter()
}, .any(|last| current.address == last.address && current.numcl > last.numcl)
)) }) && params.online.is_none_or(|online| current.numcl >= online)
} else { && params
error!("Make sure `xash3d-query` is installed!"); .servers
Err(Status::InternalServerError) .as_ref()
.is_none_or(|servers| servers.iter().any(|address| address == &current.address))
{
f.push(
&state.update,
&current.address,
&current.host,
&current.map,
current.numcl,
)
}
}
} }
Ok(RawXml(f.commit()))
} }
#[launch] #[launch]
fn rocket() -> _ { async fn rocket() -> _ {
use clap::Parser; use clap::Parser;
let argument = argument::Argument::parse(); let argument = argument::Argument::parse();
let config: config::Config = let config: config::Config =
toml::from_str(&std::fs::read_to_string(argument.config).unwrap()).unwrap(); 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() rocket::build()
.attach(Template::fairing()) .attach(Template::fairing())
.configure(rocket::Config { .configure(rocket::Config {
@ -76,6 +143,7 @@ fn rocket() -> _ {
rocket::Config::release_default() rocket::Config::release_default()
} }
}) })
.manage(online)
.manage(Global { .manage(Global {
masters: config.masters, masters: config.masters,
query: config.query, query: config.query,
@ -84,5 +152,26 @@ fn rocket() -> _ {
title: config.title, title: config.title,
version: env!("CARGO_PKG_VERSION").into(), 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<SocketAddr>,
) -> Result<std::process::Output, std::io::Error> {
// @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::<Vec<_>>()
.join(","),
)
.arg("-j")
.output()
} }

View file

@ -1,7 +1,7 @@
use rocket::serde::{Deserialize, Serialize}; use rocket::serde::{Deserialize, Serialize};
use std::net::SocketAddr; use std::net::SocketAddr;
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct Result { pub struct Result {
pub protocol: Vec<i32>, pub protocol: Vec<i32>,
@ -12,7 +12,7 @@ pub struct Result {
pub servers: Vec<Info>, pub servers: Vec<Info>,
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct Info { pub struct Info {
pub time: i64, pub time: i64,

View file

@ -2,46 +2,56 @@
{% block content %} {% block content %}
<h2>Game</h2> <h2>Game</h2>
{% if servers %} {% if servers %}
<table> <form name="rss" method="get" action="/rss">
<thead> <table>
<tr> <thead>
<th>Address</th> <tr>
<th>Host</th> <th></th>
<th>Ping</th> <th>Address</th>
<th>Protocol</th> <th>Host</th>
<th>Gamedir</th> <th>Ping</th>
<th>Map</th> <th>Protocol</th>
<th>Team</th> <th>Gamedir</th>
<th>Coop</th> <th>Map</th>
<th>Password</th> <th>Team</th>
<th>Dedicated</th> <th>Coop</th>
<th>DM</th> <th>Password</th>
<th>Max</th> <th>Dedicated</th>
<th>Online</th> <th>DM</th>
<th>Status</th> <th>Max</th>
</tr> <th>Online</th>
<thead> <th>Status</th>
</tbody> </tr>
{% for server in servers %} <thead>
<tr> </tbody>
<td>{{ server.address }}</td> {% for server in servers %}
<td>{{ server.host }}</td> <tr>
<td>{{ server.ping }}</td> <td><input type="checkbox" name="server[]" value="{{ server.address }}" /></td>
<td>{{ server.protocol }}</td> <td>{{ server.address }}</td>
<td>{{ server.gamedir }}</td> <td>{{ server.host }}</td>
<td>{{ server.map }}</td> <td>{{ server.ping }}</td>
<td>{{ server.team }}</td> <td>{{ server.protocol }}</td>
<td>{{ server.coop }}</td> <td>{{ server.gamedir }}</td>
<td>{{ server.password }}</td> <td>{{ server.map }}</td>
<td>{{ server.dedicated }}</td> <td>{{ server.team }}</td>
<td>{{ server.dm }}</td> <td>{{ server.coop }}</td>
<td>{{ server.maxcl }}</td> <td>{{ server.password }}</td>
<td>{{ server.numcl }}</td> <td>{{ server.dedicated }}</td>
<td>{{ server.status }}</td> <td>{{ server.dm }}</td>
</tr> <td>{{ server.maxcl }}</td>
{% endfor %} <td>{{ server.numcl }}</td>
</tbody> <td>{{ server.status }}</td>
</table> </tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<label>
min. online: <input type="number" name="online" min="0" value="1" />
</label>
<input type="submit" value="RSS" />
</fieldset>
</form>
{% else %} {% else %}
<div> <div>
<p>Nobody.</p> <p>Nobody.</p>

View file

@ -9,7 +9,7 @@
padding: 0; padding: 0;
font-family: monospace; font-family: monospace;
color-scheme: light dark; color-scheme: light dark;
--container-max-width: 1024px; --container-max-width: 1128px;
--color-success: #4bc432; --color-success: #4bc432;
--color-warning: #f37b21; --color-warning: #f37b21;
--color-error: #ff6363; --color-error: #ff6363;
@ -75,12 +75,39 @@
position: relative; position: relative;
} }
header > div {
display: inline-block;
}
header > div:first-child {
float: left;
}
header > div:last-child {
float: right;
text-align: right;
}
main { main {
display: block; display: block;
margin: 16px auto; margin: 16px auto;
max-width: var(--container-max-width); 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 { footer {
display: block; display: block;
margin: 16px auto; margin: 16px auto;
@ -101,10 +128,7 @@
<body> <body>
<header> <header>
<h1></h1> <h1></h1>
<div class="float-left"> <div>
</div>
<div class="float-right">
<strong> <strong>
<a href="/">{{ title }}</a> | <a href="/">{{ title }}</a> |
<a href="https://store.steampowered.com/app/70/HalfLife/" target="_blank">Game</a> | <a href="https://store.steampowered.com/app/70/HalfLife/" target="_blank">Game</a> |
@ -113,18 +137,21 @@
<a href="https://github.com/YGGverse/hlstate-rs" target="_blank" title="v{{ version }}">GitHub</a> <a href="https://github.com/YGGverse/hlstate-rs" target="_blank" title="v{{ version }}">GitHub</a>
</strong> </strong>
</div> </div>
<div>
{% if updated %}{{ updated }}{% endif %}
</div>
</header> </header>
<main> <main>
{% block content %}{% endblock content %} {% block content %}{% endblock content %}
</main> </main>
<footer> <footer>
<h2>Master</h2> <h2>Masters</h2>
{% if masters %} {% if masters %}
<ul> <ul>
{% for master in masters %} {% for master in masters %}
<li{# class="online" #}>{{ master }}</li> <li{# class="online" #}>{{ master }}</li>
{% endfor %} {% endfor %}
<ul> </ul>
{% endif %} {% endif %}
</footer> </footer>
</body> </body>