diff --git a/Cargo.lock b/Cargo.lock index 38691ff..c0bec6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -291,6 +291,7 @@ dependencies = [ "fedserv-inspircd", "fedserv-memoserv", "fedserv-nickserv", + "fedserv-statserv", "hmac", "pbkdf2", "prost", @@ -356,6 +357,13 @@ dependencies = [ "fedserv-api", ] +[[package]] +name = "fedserv-statserv" +version = "0.0.1" +dependencies = [ + "fedserv-api", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" diff --git a/Cargo.toml b/Cargo.toml index 15b1066..c7afb0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv"] +members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv"] [package] name = "fedserv" @@ -15,6 +15,7 @@ fedserv-nickserv = { path = "nickserv" } fedserv-example = { path = "example" } fedserv-botserv = { path = "botserv" } fedserv-memoserv = { path = "memoserv" } +fedserv-statserv = { path = "statserv" } tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/api/src/lib.rs b/api/src/lib.rs index 768e8ad..3410c1f 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -646,6 +646,8 @@ pub trait NetView { fn last_seen(&self, nick: &str) -> Option; // BOTSTATS: (lines seen this session, top talkers by count desc). fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)>; + // The shared stat counters (namespaced key -> count), for StatServ. + fn stat_counters(&self) -> Vec<(String, u64)>; } // A pseudo-client (NickServ, ChanServ, ...). Introduced at burst, receives the diff --git a/botserv/src/lib.rs b/botserv/src/lib.rs index 4f3b262..022bb0f 100644 --- a/botserv/src/lib.rs +++ b/botserv/src/lib.rs @@ -22,8 +22,6 @@ mod badwords; mod copy; #[path = "trigger.rs"] mod trigger; -#[path = "stats.rs"] -mod stats; // Shared gate: the sender may administer 's bot options only as its // founder or a services admin. Notices and returns false on failure. @@ -68,7 +66,6 @@ impl Service for BotServ { Some("BADWORDS") => badwords::handle(me, from, args, ctx, db), Some("COPY") => copy::handle(me, from, args, ctx, db), Some("TRIGGER") => trigger::handle(me, from, args, ctx, db), - Some("BOTSTATS") | Some("STATS") => stats::handle(me, from, args, ctx, net, db), Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 shows details, \x02SAY\x02/\x02ACT\x02 <#channel> speaks through the bot, \x02SET\x02 <#channel> GREET toggles greets, \x02KICK\x02 <#channel> configures kickers (with \x02TEST\x02/\x02WARN\x02/\x02TTB\x02), \x02BADWORDS\x02 and \x02TRIGGER\x02 <#channel> ADD|DEL|LIST manage badword regexes and auto-responses, \x02COPY\x02 <#src> <#dst> clones settings. Operators also have \x02BOT\x02 ADD|CHANGE|DEL|LIST."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), } diff --git a/src/config.rs b/src/config.rs index 7cee6a4..cd95b69 100644 --- a/src/config.rs +++ b/src/config.rs @@ -69,7 +69,7 @@ impl Default for Modules { } fn default_services() -> Vec { - vec!["nickserv".to_string(), "chanserv".to_string(), "botserv".to_string(), "memoserv".to_string()] + vec!["nickserv".to_string(), "chanserv".to_string(), "botserv".to_string(), "memoserv".to_string(), "statserv".to_string()] } #[derive(Debug, Deserialize, Clone)] diff --git a/src/engine/mod.rs b/src/engine/mod.rs index f1c05e9..c7edb6d 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -118,10 +118,6 @@ pub struct Engine { trigger_cache: HashMap, // Kicker bans (times-to-ban) awaiting BANEXPIRE removal. Swept on each event. pending_unbans: Vec, - // Shared, namespaced stat counters any service contributes to (via - // ServiceCtx::count) plus engine-internal events; exposed over the gRPC - // Stats API. Ordered so a snapshot is stable. - stats: std::collections::BTreeMap, // In-flight community !votekick/!voteban tallies, keyed by (channel_lc, // target_nick_lc). Ephemeral; expire after VOTE_TTL. votes: HashMap<(String, String), VoteState>, @@ -195,20 +191,20 @@ impl Engine { badword_cache: HashMap::new(), trigger_cache: HashMap::new(), pending_unbans: Vec::new(), - stats: std::collections::BTreeMap::new(), votes: HashMap::new(), } } - // Increment a shared stat counter. + // Increment a shared stat counter (held on the network view, so services and + // the gRPC API read from one place). pub fn bump(&mut self, key: &str) { - *self.stats.entry(key.to_string()).or_insert(0) += 1; + self.network.bump(key); } // The shared counters plus live gauges derived from the store, for the gRPC // Stats API. Any service's counters ride in here alongside these. pub fn stats_snapshot(&self) -> std::collections::BTreeMap { - let mut m = self.stats.clone(); + let mut m: std::collections::BTreeMap = self.network.stat_counters().clone(); m.insert("accounts.total".to_string(), self.db.accounts().count() as u64); m.insert("channels.total".to_string(), self.db.channels().count() as u64); m.insert("bots.total".to_string(), self.db.bots().count() as u64); @@ -1019,7 +1015,7 @@ impl Engine { } } } - // BOTSTATS: count activity in channels that have a bot. + // Record activity in bot channels (surfaced by StatServ). if self.db.channel(to).is_some_and(|c| c.assigned_bot.is_some()) { self.network.record_line(to, &nick); self.bump("botserv.messages"); @@ -2637,18 +2633,51 @@ mod tests { assert!(kicked(&vote(&mut e, "000AAAAAC", "!votekick victim")), "2 distinct votes: kicked"); } - // BOTSTATS reports per-channel activity the bot has seen this session. + // StatServ reports per-channel activity (#channel) and the global registry + // (SERVER, operators only). #[test] - fn botserv_botstats_reports_activity() { - let (mut e, _p) = kicker_fixture("bsbotstats"); + fn statserv_reports_channel_and_global() { + use fedserv_botserv::BotServ; + use fedserv_nickserv::NickServ; + use fedserv_statserv::StatServ; + let path = std::env::temp_dir().join("fedserv-statserv.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("boss", "password1", None).unwrap(); + db.register_channel("#c", "boss").unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(BotServ { uid: "42SAAAAAD".into() }), + Box::new(StatServ { uid: "42SAAAAAF".into() }), + ], + db, + ); + e.set_sid("42S".into()); + let mut opers = std::collections::HashMap::new(); + opers.insert("boss".to_string(), Privs::default().with(fedserv_api::Priv::Admin).with(fedserv_api::Priv::Auspex)); + e.set_opers(opers); let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() }); + let ss = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAF".into(), text: t.into() }); let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "spammer".into(), host: "h".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + bs(&mut e, "BOT ADD Bendy bot serv.host Helper"); + bs(&mut e, "ASSIGN #c Bendy"); say(&mut e, "one"); say(&mut e, "two"); say(&mut e, "three"); - let out = bs(&mut e, "BOTSTATS #c"); - assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("line(s) seen this session"))), "line count: {out:?}"); - assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("spammer") && text.contains("3"))), "top talker listed: {out:?}"); + + // Per-channel view. + let out = ss(&mut e, "#c"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("line(s) seen this session"))), "channel lines: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("spammer") && text.contains("3"))), "top talker: {out:?}"); + // Global view (oper) shows the shared counters. + let out = ss(&mut e, "SERVER"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("botserv.messages"))), "global counters: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("channels.total"))), "gauge shown"); } // The shared stats registry gathers per-service command counts, BotServ diff --git a/src/engine/state.rs b/src/engine/state.rs index 0489e50..571b126 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::time::{SystemTime, UNIX_EPOCH}; // The read-only network view a module sees; re-exported so the engine keeps @@ -17,6 +17,9 @@ pub struct Network { // `users` so a reconnect can clear them wholesale without disturbing the // uplink-sourced user map. bots: HashMap, + // Shared, namespaced stat counters any service contributes to (ephemeral, + // ordered for a stable snapshot). Read by StatServ and the gRPC Stats API. + stats: BTreeMap, } pub struct User { @@ -196,6 +199,16 @@ impl Network { } } + // Increment a shared stat counter. + pub fn bump(&mut self, key: &str) { + *self.stats.entry(key.to_string()).or_insert(0) += 1; + } + + // The raw shared counters (sorted). Gauges are merged in by the reader. + pub fn stat_counters(&self) -> &BTreeMap { + &self.stats + } + // BOTSTATS view: (total lines, top talkers by count, descending). pub fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)> { let c = self.channels.get(&lc(channel))?; @@ -260,4 +273,7 @@ impl NetView for Network { fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)> { Network::channel_activity(self, channel) } + fn stat_counters(&self) -> Vec<(String, u64)> { + Network::stat_counters(self).iter().map(|(k, v)| (k.clone(), *v)).collect() + } } diff --git a/src/main.rs b/src/main.rs index 822d071..00ead70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ use engine::Engine; use fedserv_botserv::BotServ; use fedserv_chanserv::ChanServ; use fedserv_memoserv::MemoServ; +use fedserv_statserv::StatServ; use fedserv_example::ExampleServ; use fedserv_inspircd::InspIrcd; use fedserv_nickserv::NickServ; @@ -69,6 +70,11 @@ async fn main() -> Result<()> { uid: format!("{}AAAAAE", cfg.server.sid), })); } + if enabled("statserv") { + services.push(Box::new(StatServ { + uid: format!("{}AAAAAF", cfg.server.sid), + })); + } if enabled("example") { services.push(Box::new(ExampleServ { uid: format!("{}AAAAAC", cfg.server.sid), diff --git a/statserv/Cargo.toml b/statserv/Cargo.toml new file mode 100644 index 0000000..03e034f --- /dev/null +++ b/statserv/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "fedserv-statserv" +version = "0.0.1" +edition = "2021" +description = "StatServ: surface the shared stats registry and per-channel activity." + +[dependencies] +fedserv-api = { path = "../api" } diff --git a/botserv/src/stats.rs b/statserv/src/channel.rs similarity index 63% rename from botserv/src/stats.rs rename to statserv/src/channel.rs index 5906f96..2e888ed 100644 --- a/botserv/src/stats.rs +++ b/statserv/src/channel.rs @@ -1,12 +1,8 @@ use fedserv_api::{NetView, Sender, ServiceCtx, Store}; -// BOTSTATS <#channel>: session activity the bot has seen in a channel — total -// lines and the top talkers. Founder-or-admin. -pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { - let Some(&chan) = args.get(1) else { - ctx.notice(me, from.uid, "Syntax: BOTSTATS <#channel>"); - return; - }; +// <#channel>: the lines seen in a channel this session and its top talkers. +// Founder-or-admin. +pub fn handle(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { if !super::require_channel_admin(me, from, chan, ctx, db) { return; } diff --git a/statserv/src/global.rs b/statserv/src/global.rs new file mode 100644 index 0000000..8ce7e7d --- /dev/null +++ b/statserv/src/global.rs @@ -0,0 +1,21 @@ +use fedserv_api::{NetView, Priv, Sender, ServiceCtx, Store}; + +// SERVER: the shared, cross-service counter registry plus a couple of live +// gauges. Operators only (Priv::Auspex), since it is network-wide. +pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) { + if !from.privs.has(Priv::Auspex) { + ctx.notice(me, from.uid, "Network statistics are for services operators."); + return; + } + ctx.notice(me, from.uid, "Network statistics:"); + ctx.notice(me, from.uid, format!(" channels.total: {}", db.channels().len())); + ctx.notice(me, from.uid, format!(" bots.total: {}", db.bots().len())); + let counters = net.stat_counters(); + if counters.is_empty() { + ctx.notice(me, from.uid, " (no activity counters yet)"); + return; + } + for (key, value) in counters { + ctx.notice(me, from.uid, format!(" {key}: {value}")); + } +} diff --git a/statserv/src/lib.rs b/statserv/src/lib.rs new file mode 100644 index 0000000..09c559b --- /dev/null +++ b/statserv/src/lib.rs @@ -0,0 +1,56 @@ +//! StatServ surfaces statistics: the shared, cross-service counter registry +//! (SERVER, operators only) and per-channel activity the bots have seen +//! (a #channel argument, for its founder). `lib.rs` holds the dispatcher; each +//! view lives in its own file. + +use fedserv_api::{NetView, Priv, Sender, Service, ServiceCtx, Store}; + +#[path = "global.rs"] +mod global; +#[path = "channel.rs"] +mod channel; + +pub struct StatServ { + pub uid: String, +} + +impl Service for StatServ { + fn nick(&self) -> &str { + "StatServ" + } + fn uid(&self) -> &str { + &self.uid + } + fn gecos(&self) -> &str { + "Statistics Service" + } + + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { + let me = self.uid.as_str(); + match args.first().copied() { + Some(chan) if chan.starts_with('#') => channel::handle(me, from, chan, ctx, net, db), + Some(cmd) if cmd.eq_ignore_ascii_case("SERVER") || cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, ctx, net, db), + Some(cmd) if cmd.eq_ignore_ascii_case("HELP") => help(me, from, ctx), + None => help(me, from, ctx), + Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02SERVER\x02, a \x02#channel\x02, or \x02HELP\x02.")), + } + } +} + +fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) { + ctx.notice(me, from.uid, "StatServ reports statistics: \x02SERVER\x02 for network-wide counters (operators), or \x02<#channel>\x02 for a channel's activity (its founder)."); +} + +// Shared gate: the sender may see a channel's stats only as its founder or a +// services admin. +fn require_channel_admin(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &dyn Store) -> bool { + let Some(founder) = db.channel(chan).map(|c| c.founder) else { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); + return false; + }; + if from.account != Some(founder.as_str()) && !from.privs.has(Priv::Admin) { + ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can see its stats.")); + return false; + } + true +}