diff --git a/Cargo.lock b/Cargo.lock index e4afcd5..370833e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,6 +285,7 @@ dependencies = [ "argon2", "base64", "fedserv-api", + "fedserv-botserv", "fedserv-chanserv", "fedserv-example", "fedserv-inspircd", @@ -311,6 +312,13 @@ dependencies = [ name = "fedserv-api" version = "0.0.1" +[[package]] +name = "fedserv-botserv" +version = "0.0.1" +dependencies = [ + "fedserv-api", +] + [[package]] name = "fedserv-chanserv" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 8e5b167..fa4b001 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["api", "inspircd", "chanserv", "nickserv", "example"] +members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv"] [package] name = "fedserv" @@ -13,6 +13,7 @@ fedserv-inspircd = { path = "inspircd" } fedserv-chanserv = { path = "chanserv" } fedserv-nickserv = { path = "nickserv" } fedserv-example = { path = "example" } +fedserv-botserv = { path = "botserv" } 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 109ea5e..6b6ac62 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -308,6 +308,15 @@ pub struct ChanAkickView { pub reason: String, } +// A service bot registered with BotServ. +#[derive(Debug, Clone)] +pub struct BotView { + pub nick: String, + pub user: String, + pub host: String, + pub gecos: String, +} + // A services suspension on an account: who, why, when, and an optional expiry // (absolute unix seconds; None = until manually lifted). #[derive(Debug, Clone)] @@ -496,6 +505,10 @@ pub trait Store { fn unsuspend_channel(&mut self, channel: &str) -> Result; fn is_channel_suspended(&self, channel: &str) -> bool; fn channel_suspension(&self, channel: &str) -> Option; + // BotServ registry (oper-only at the command layer). + fn bot_add(&mut self, nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError>; + fn bot_del(&mut self, nick: &str) -> Result; + fn bots(&self) -> Vec; fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError>; fn set_founder(&mut self, channel: &str, account: &str) -> Result<(), ChanError>; fn access_add(&mut self, channel: &str, account: &str, level: &str) -> Result<(), ChanError>; diff --git a/botserv/Cargo.toml b/botserv/Cargo.toml new file mode 100644 index 0000000..f566044 --- /dev/null +++ b/botserv/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "fedserv-botserv" +version = "0.0.1" +edition = "2021" +description = "BotServ: registers and manages service bots that sit in channels." + +[dependencies] +fedserv-api = { path = "../api" } diff --git a/botserv/src/lib.rs b/botserv/src/lib.rs new file mode 100644 index 0000000..bee1279 --- /dev/null +++ b/botserv/src/lib.rs @@ -0,0 +1,76 @@ +//! BotServ registers service bots — pseudo-clients that (in later slices) get +//! assigned to channels and run fantasy commands. This first slice is the bot +//! registry: BOT ADD/DEL/LIST, gated on the typed Priv::Admin. + +use fedserv_api::{NetView, Priv, Sender, Service, ServiceCtx, Store}; + +pub struct BotServ { + pub uid: String, +} + +impl Service for BotServ { + fn nick(&self) -> &str { + "BotServ" + } + fn uid(&self) -> &str { + &self.uid + } + fn gecos(&self) -> &str { + "Bot Services" + } + + 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().map(|s| s.to_ascii_uppercase()).as_deref() { + Some("BOT") => bot(me, from, args, ctx, db), + Some("HELP") => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels. Operator commands: \x02BOT\x02 ADD|DEL|LIST."), + Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), + None => {} + } + } +} + +// BOT ADD [gecos] | BOT DEL | BOT LIST — manage the +// bot registry. Administering bots is oper-only (Priv::Admin). +fn bot(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !from.privs.has(Priv::Admin) { + ctx.notice(me, from.uid, "Access denied — managing bots is for services operators."); + return; + } + match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { + Some("ADD") => { + let (Some(&nick), Some(&user), Some(&host)) = (args.get(2), args.get(3), args.get(4)) else { + ctx.notice(me, from.uid, "Syntax: BOT ADD [gecos]"); + return; + }; + let gecos = if args.len() > 5 { args[5..].join(" ") } else { "Service Bot".to_string() }; + match db.bot_add(nick, user, host, &gecos) { + Ok(()) => ctx.notice(me, from.uid, format!("Bot \x02{nick}\x02 (\x02{user}@{host}\x02) added.")), + Err(_) => ctx.notice(me, from.uid, format!("A bot named \x02{nick}\x02 already exists, or that didn't work.")), + } + } + Some("DEL") => { + let Some(&nick) = args.get(2) else { + ctx.notice(me, from.uid, "Syntax: BOT DEL "); + return; + }; + match db.bot_del(nick) { + Ok(true) => ctx.notice(me, from.uid, format!("Bot \x02{nick}\x02 deleted.")), + Ok(false) => ctx.notice(me, from.uid, format!("There's no bot named \x02{nick}\x02.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + None | Some("LIST") => { + let bots = db.bots(); + if bots.is_empty() { + ctx.notice(me, from.uid, "No bots have been added yet. Add one with \x02BOT ADD\x02."); + return; + } + ctx.notice(me, from.uid, format!("Bots ({}):", bots.len())); + for b in &bots { + ctx.notice(me, from.uid, format!(" \x02{}\x02 ({}@{}) — {}", b.nick, b.user, b.host, b.gecos)); + } + } + Some(other) => ctx.notice(me, from.uid, format!("Unknown BOT command \x02{other}\x02. Use \x02ADD\x02, \x02DEL\x02 or \x02LIST\x02.")), + } +} diff --git a/src/config.rs b/src/config.rs index 0655ad7..69804c2 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()] + vec!["nickserv".to_string(), "chanserv".to_string(), "botserv".to_string()] } #[derive(Debug, Deserialize, Clone)] diff --git a/src/engine/db.rs b/src/engine/db.rs index d2dde66..34054b0 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -15,7 +15,7 @@ use super::scram::{self, Hash}; // fedserv-api SDK crate; re-exported so the engine keeps naming them locally and // modules importing `crate::engine::db::{ChanError, ...}` are unaffected. pub use fedserv_api::{ - AccountView, AjoinView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, RegError, Store, + AccountView, AjoinView, BotView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, RegError, Store, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -91,6 +91,8 @@ pub enum Event { ChannelTopicSet { channel: String, topic: String }, ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option }, ChannelUnsuspended { channel: String }, + BotAdded(Bot), + BotRemoved { nick: String }, } // Whether an event replicates across the federation. Account identity is Global @@ -132,7 +134,9 @@ impl Event { | Event::ChannelSettingsSet { .. } | Event::ChannelTopicSet { .. } | Event::ChannelSuspended { .. } - | Event::ChannelUnsuspended { .. } => Scope::Local, + | Event::ChannelUnsuspended { .. } + | Event::BotAdded(_) + | Event::BotRemoved { .. } => Scope::Local, } } } @@ -162,6 +166,15 @@ pub struct Suspension { pub expires: Option, } +// A service bot: a pseudo-client BotServ can assign to sit in channels. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Bot { + pub nick: String, + pub user: String, + pub host: String, + pub gecos: String, +} + // An auto-join entry: a channel this account is joined to on identify, with an // optional key for keyed channels. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -538,6 +551,8 @@ pub struct Db { codes: HashMap, // Node-local, non-persisted brute-force throttle for password authentication. auth_fails: HashMap, + // Registered service bots (BotServ), keyed by casefolded nick. + bots: HashMap, } // A pending emailed code and how many wrong guesses remain before it is burned. @@ -575,11 +590,12 @@ impl Db { let mut accounts = HashMap::new(); let mut channels = HashMap::new(); let mut grouped = HashMap::new(); + let mut bots = HashMap::new(); for event in events { - apply(&mut accounts, &mut channels, &mut grouped, event); + apply(&mut accounts, &mut channels, &mut grouped, &mut bots, event); } tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); - Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new() } + Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), bots } } /// Fold an entry authored by another node into the store — the services-side @@ -595,7 +611,7 @@ impl Db { _ => None, }; if let Some(event) = self.log.ingest(entry)? { - apply(&mut self.accounts, &mut self.channels, &mut self.grouped, event); + apply(&mut self.accounts, &mut self.channels, &mut self.grouped, &mut self.bots, event); } if let Some((name, prev_home)) = watched { match (prev_home, self.account(&name).map(|c| c.home.clone())) { @@ -641,6 +657,9 @@ impl Db { for (nick, account) in &self.grouped { snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() }); } + for b in self.bots.values() { + snapshot.push(Event::BotAdded(b.clone())); + } self.log.compact(snapshot)?; tracing::info!(before, after = self.log.len(), "compacted event log"); Ok(()) @@ -1267,6 +1286,34 @@ impl Db { self.channels.get(&key(channel)).and_then(|c| c.suspension.as_ref()).map(|s| SuspensionView { by: s.by.clone(), reason: s.reason.clone(), ts: s.ts, expires: s.expires }) } + /// Register a service bot. + pub fn bot_add(&mut self, nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError> { + let k = key(nick); + if self.bots.contains_key(&k) { + return Err(ChanError::Exists); + } + let bot = Bot { nick: nick.to_string(), user: user.to_string(), host: host.to_string(), gecos: gecos.to_string() }; + self.log.append(Event::BotAdded(bot.clone())).map_err(|_| ChanError::Internal)?; + self.bots.insert(k, bot); + Ok(()) + } + + /// Delete a service bot. Returns whether it existed. + pub fn bot_del(&mut self, nick: &str) -> Result { + let k = key(nick); + if !self.bots.contains_key(&k) { + return Ok(false); + } + self.log.append(Event::BotRemoved { nick: nick.to_string() }).map_err(|_| ChanError::Internal)?; + self.bots.remove(&k); + Ok(true) + } + + /// All registered bots. + pub fn bots(&self) -> impl Iterator { + self.bots.values() + } + /// Set `channel`'s entry message (empty clears it). pub fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> { let k = key(channel); @@ -1304,7 +1351,7 @@ fn owns_over(held: &Account, claim: &Account) -> bool { // Fold one event into the store. Shared by log replay (`open`) and gossip // ingest, so both routes reconstruct identical state. -fn apply(accounts: &mut HashMap, channels: &mut HashMap, grouped: &mut HashMap, event: Event) { +fn apply(accounts: &mut HashMap, channels: &mut HashMap, grouped: &mut HashMap, bots: &mut HashMap, event: Event) { match event { Event::AccountRegistered(a) => { // Resolve a concurrent registration of the same name deterministically: @@ -1441,6 +1488,12 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + bots.insert(key(&b.nick), b); + } + Event::BotRemoved { nick } => { + bots.remove(&key(&nick)); + } Event::ChannelEntryMsgSet { channel, msg } => { if let Some(c) = channels.get_mut(&key(&channel)) { c.entrymsg = msg; @@ -1642,6 +1695,15 @@ impl Store for Db { fn channel_suspension(&self, channel: &str) -> Option { Db::channel_suspension(self, channel) } + fn bot_add(&mut self, nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError> { + Db::bot_add(self, nick, user, host, gecos) + } + fn bot_del(&mut self, nick: &str) -> Result { + Db::bot_del(self, nick) + } + fn bots(&self) -> Vec { + Db::bots(self).map(|b| BotView { nick: b.nick.clone(), user: b.user.clone(), host: b.host.clone(), gecos: b.gecos.clone() }).collect() + } fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> { Db::set_entrymsg(self, channel, msg) } @@ -1721,9 +1783,9 @@ mod tests { ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, }; let converge = |first: &Account, second: &Account| { - let (mut acc, mut ch, mut gr) = (HashMap::new(), HashMap::new(), HashMap::new()); - apply(&mut acc, &mut ch, &mut gr, Event::AccountRegistered(first.clone())); - apply(&mut acc, &mut ch, &mut gr, Event::AccountRegistered(second.clone())); + let (mut acc, mut ch, mut gr, mut bo) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new()); + apply(&mut acc, &mut ch, &mut gr, &mut bo, Event::AccountRegistered(first.clone())); + apply(&mut acc, &mut ch, &mut gr, &mut bo, Event::AccountRegistered(second.clone())); acc["alice"].password_hash.clone() }; // Earlier registration wins, regardless of which claim applies first. @@ -1874,6 +1936,28 @@ mod tests { assert_eq!(db.channel_suspension("#c").unwrap().by, "oper"); } + // The bot registry adds (case-insensitively unique), deletes, and replays. + #[test] + fn bots_add_del_and_persist() { + let p = tmp("bots"); + { + let mut db = Db::open(&p, "N1"); + assert_eq!(db.bots().count(), 0); + db.bot_add("Bendy", "bot", "services.host", "A helper").unwrap(); + assert!(matches!(db.bot_add("bendy", "x", "y", "z"), Err(ChanError::Exists)), "dup is case-insensitive"); + assert_eq!(db.bots().count(), 1); + assert!(db.bot_del("BENDY").unwrap(), "case-insensitive delete"); + assert!(!db.bot_del("bendy").unwrap(), "already gone"); + } + { + let mut db = Db::open(&p, "N1"); + db.bot_add("Botty", "b", "h", "g").unwrap(); + } + let db = Db::open(&p, "N1"); + assert_eq!(db.bots().count(), 1, "bots replay from the log"); + assert_eq!(db.bots().next().unwrap().nick, "Botty"); + } + // A wrong code is tolerated a few times, then the code is burned so it can't // be ground down online even though it is short. #[test] diff --git a/src/engine/mod.rs b/src/engine/mod.rs index ac6e9c7..422259d 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1557,6 +1557,40 @@ mod tests { assert!(out.is_empty(), "no enforcement when SECUREOPS is off: {out:?}"); } + // BotServ BOT ADD/LIST is oper-gated (Priv::Admin) and the bot is remembered. + #[test] + fn botserv_bot_add_list_is_oper_gated() { + use fedserv_botserv::BotServ; + use fedserv_nickserv::NickServ; + let path = std::env::temp_dir().join("fedserv-botserv.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("staff", "password1", None).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() }), + ], + db, + ); + let mut opers = std::collections::HashMap::new(); + opers.insert("staff".to_string(), Privs::default().with(fedserv_api::Priv::Admin)); + e.set_opers(opers); + let bs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAD".into(), text: t.into() }); + let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n))); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "rando".into(), host: "h".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "staff".into(), host: "h".into() }); + + // A non-oper cannot add a bot. + assert!(notice(&bs(&mut e, "000AAAAAB", "BOT ADD Bendy bot serv.host Helper"), "Access denied")); + // The admin oper identifies, adds a bot, and sees it listed. + e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + assert!(notice(&bs(&mut e, "000AAAAAC", "BOT ADD Bendy bot serv.host A Helper"), "added")); + assert!(notice(&bs(&mut e, "000AAAAAC", "BOT LIST"), "Bendy")); + } + // Channel SUSPEND is oper-gated and freezes founder management until lifted. #[test] fn channel_suspend_is_oper_gated_and_freezes_management() { diff --git a/src/grpc.rs b/src/grpc.rs index 64e6ab6..b3ea865 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -135,7 +135,9 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::ChannelSettingsSet { .. } | Event::ChannelTopicSet { .. } | Event::ChannelSuspended { .. } - | Event::ChannelUnsuspended { .. } => return None, + | Event::ChannelUnsuspended { .. } + | Event::BotAdded(_) + | Event::BotRemoved { .. } => return None, }; Some(ReplicationEvent { origin: entry.origin().to_string(), seq: entry.seq(), lamport: entry.lamport(), kind: Some(kind) }) } diff --git a/src/main.rs b/src/main.rs index 3a9c3f7..6139ad1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::Mutex; use engine::Engine; +use fedserv_botserv::BotServ; use fedserv_chanserv::ChanServ; use fedserv_example::ExampleServ; use fedserv_inspircd::InspIrcd; @@ -57,6 +58,11 @@ async fn main() -> Result<()> { uid: format!("{}AAAAAB", cfg.server.sid), })); } + if enabled("botserv") { + services.push(Box::new(BotServ { + uid: format!("{}AAAAAD", cfg.server.sid), + })); + } if enabled("example") { services.push(Box::new(ExampleServ { uid: format!("{}AAAAAC", cfg.server.sid),