From 149ab69afe371602443efb83eca5ece99f987293 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 30 Aug 2026 19:54:10 +0000 Subject: [PATCH] stats: scrape the ircd metrics endpoint over http (no irc client, no snote spam); add channels --- src/main.rs | 7 +++- src/stats.rs | 93 +++++++++++++++----------------------------- templates/index.html | 1 + 3 files changed, 37 insertions(+), 64 deletions(-) diff --git a/src/main.rs b/src/main.rs index 216514d..cdb48a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,15 +20,18 @@ use tower_http::{compression::CompressionLayer, trace::TraceLayer}; struct Status { online: bool, users: String, + channels: String, version: String, } fn view(shared: &stats::Shared) -> Status { let s = shared.read().ok().map(|g| g.clone()).unwrap_or_default(); + let fmt = |n: Option| n.map(|v| v.to_string()).unwrap_or_else(|| "—".into()); Status { online: s.online, - users: s.users.map(|n| n.to_string()).unwrap_or_else(|| "—".into()), - version: s.version.unwrap_or_else(|| "5.0.0".into()), + users: fmt(s.users), + channels: fmt(s.channels), + version: "5.0.0".into(), } } diff --git a/src/stats.rs b/src/stats.rs index 8266d83..26b401e 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -1,11 +1,11 @@ -//! Live network status, polled from the running ircd. A background task connects -//! to the local daemon as an ordinary client every minute, runs LUSERS/VERSION, -//! parses the reply, and stores it. If the daemon is unreachable the state simply -//! reads "offline" — it never blocks or breaks page rendering. +//! Live network status, scraped from the ircd's Prometheus/OpenMetrics endpoint +//! (`metrics_bind` on the daemon). A background task GETs it every minute over +//! plain HTTP on localhost — no IRC client, so it never shows up as a connecting +//! user. If the endpoint is unreachable the state simply reads "offline". use std::sync::{Arc, RwLock}; use std::time::Duration; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::time::{sleep, timeout}; @@ -13,7 +13,7 @@ use tokio::time::{sleep, timeout}; pub struct NetStats { pub online: bool, pub users: Option, - pub version: Option, + pub channels: Option, } pub type Shared = Arc>; @@ -22,7 +22,7 @@ pub type Shared = Arc>; pub fn spawn_updater(shared: Shared) { tokio::spawn(async move { loop { - let next = match timeout(Duration::from_secs(7), probe()).await { + let next = match timeout(Duration::from_secs(6), probe()).await { Ok(Some(s)) => s, _ => NetStats::default(), }; @@ -35,67 +35,36 @@ pub fn spawn_updater(shared: Shared) { } async fn probe() -> Option { - let addr = std::env::var("ECHO_IRCD_ADDR").unwrap_or_else(|_| "127.0.0.1:6667".into()); - let stream = TcpStream::connect(&addr).await.ok()?; - let (rd, mut wr) = stream.into_split(); - wr.write_all(b"NICK echostat\r\nUSER echostat 0 * :echo website status\r\n") + let addr = std::env::var("ECHO_METRICS_ADDR").unwrap_or_else(|_| "127.0.0.1:9109".into()); + let mut stream = TcpStream::connect(&addr).await.ok()?; + stream + .write_all(b"GET / HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\n\r\n") .await .ok()?; - let mut lines = BufReader::new(rd).lines(); - let (mut users, mut version, mut asked) = (None, None, false); - - for _ in 0..100 { - let line = match lines.next_line().await { - Ok(Some(l)) => l, - _ => break, - }; - let line = line.trim_end_matches('\r'); - if let Some(rest) = line.strip_prefix("PING ") { - let _ = wr.write_all(format!("PONG {rest}\r\n").as_bytes()).await; - continue; - } - match numeric(line) { - Some("001") if !asked => { - let _ = wr.write_all(b"LUSERS\r\nVERSION\r\n").await; - asked = true; - } - Some("251") => users = users.or_else(|| parse_users(line)), - Some("351") => version = version.or_else(|| parse_version(line)), - _ => {} - } - if users.is_some() && version.is_some() { + let mut buf = Vec::with_capacity(4096); + let mut chunk = [0u8; 4096]; + loop { + let n = stream.read(&mut chunk).await.ok()?; + if n == 0 || buf.len() > 65536 { break; } + buf.extend_from_slice(&chunk[..n]); } - let _ = wr.write_all(b"QUIT :bye\r\n").await; - Some(NetStats { online: true, users, version }) -} + let text = String::from_utf8_lossy(&buf); + let body = text.split("\r\n\r\n").nth(1).unwrap_or(&text); -/// The three-digit numeric of an IRC line (`:src 251 nick …`), if any. -fn numeric(line: &str) -> Option<&str> { - let mut it = line.split(' '); - let first = it.next()?; - let code = if first.starts_with(':') { it.next()? } else { first }; - (code.len() == 3 && code.bytes().all(|b| b.is_ascii_digit())).then_some(code) -} - -/// RPL_LUSERCLIENT trailing: "There are 5 users on 1 server" → first number. -fn parse_users(line: &str) -> Option { - line.splitn(2, " :") - .nth(1)? - .split_whitespace() - .find_map(|w| w.parse::().ok()) -} - -/// RPL_VERSION trailing: "echoircd 5.0.0 · …" → "5.0.0". -fn parse_version(line: &str) -> Option { - let tail = line.splitn(2, " :").nth(1)?; - let mut it = tail.split_whitespace(); - let a = it.next()?; - if a.eq_ignore_ascii_case("echoircd") { - it.next().map(|v| v.to_string()) - } else { - Some(a.to_string()) + let (mut users, mut channels) = (None, None); + for line in body.lines() { + if line.starts_with('#') { + continue; + } + let mut it = line.split_whitespace(); + match (it.next(), it.next()) { + (Some("echoircd_users"), Some(v)) => users = v.parse().ok(), + (Some("echoircd_channels"), Some(v)) => channels = v.parse().ok(), + _ => {} + } } + Some(NetStats { online: true, users, channels }) } diff --git a/templates/index.html b/templates/index.html index 2323c4e..c3006ed 100644 --- a/templates/index.html +++ b/templates/index.html @@ -21,6 +21,7 @@
state
{% if status.online %}online{% else %}offline{% endif %}
users
{{ status.users }}
+
channels
{{ status.channels }}
network
echoiRCd
version
echoIRCd {{ status.version }}
tls
1.3 · X25519MLKEM768