diff --git a/Cargo.lock b/Cargo.lock index 76dea2b..2eeeb39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -331,6 +331,7 @@ dependencies = [ "fedserv-diceserv", "fedserv-example", "fedserv-hostserv", + "fedserv-infoserv", "fedserv-inspircd", "fedserv-memoserv", "fedserv-nickserv", @@ -395,6 +396,13 @@ dependencies = [ "fedserv-api", ] +[[package]] +name = "fedserv-infoserv" +version = "0.0.1" +dependencies = [ + "fedserv-api", +] + [[package]] name = "fedserv-inspircd" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index ebf73fd..527655f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv"] +members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv"] [package] name = "fedserv" @@ -19,6 +19,7 @@ fedserv-statserv = { path = "statserv" } fedserv-hostserv = { path = "hostserv" } fedserv-operserv = { path = "operserv" } fedserv-diceserv = { path = "diceserv" } +fedserv-infoserv = { path = "infoserv" } 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/infoserv/Cargo.toml b/infoserv/Cargo.toml new file mode 100644 index 0000000..8cb7a7d --- /dev/null +++ b/infoserv/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "fedserv-infoserv" +version = "0.0.1" +edition = "2021" +description = "InfoServ: network information bulletins shown on connect (public) and on oper login." + +[dependencies] +fedserv-api = { path = "../api" } diff --git a/infoserv/src/lib.rs b/infoserv/src/lib.rs new file mode 100644 index 0000000..fcbbbba --- /dev/null +++ b/infoserv/src/lib.rs @@ -0,0 +1,103 @@ +//! InfoServ manages the network information bulletins. Public bulletins are +//! shown to every user as they connect; oper bulletins are shown to operators +//! when they log in (both are displayed by the engine — the same hooks OperServ +//! once drove through its NEWS command). This is the dedicated front-end over +//! that one shared news store, so there aren't two ways to post the same thing. +//! +//! POST/LIST/DEL manage public bulletins; OPOST/OLIST/ODEL the oper ones. +//! Posting and deleting are admin-only; anyone may LIST the public bulletins, +//! and any operator may OLIST. + +use fedserv_api::{NetView, Priv, Sender, Service, ServiceCtx, Store}; + +// The two bulletin kinds in the shared news store. +const PUBLIC: &str = "logon"; +const OPER: &str = "oper"; + +pub struct InfoServ { + pub uid: String, +} + +impl Service for InfoServ { + fn nick(&self) -> &str { + "InfoServ" + } + fn uid(&self) -> &str { + &self.uid + } + fn gecos(&self) -> &str { + "Information 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().map(|s| s.to_ascii_uppercase()).as_deref() { + Some("POST") | Some("ADD") => post(me, from, PUBLIC, &args[1..], ctx, db), + Some("OPOST") | Some("OADD") => post(me, from, OPER, &args[1..], ctx, db), + Some("DEL") | Some("REMOVE") => del(me, from, PUBLIC, args.get(1).copied(), ctx, db), + Some("ODEL") => del(me, from, OPER, args.get(1).copied(), ctx, db), + // Public bulletins are, well, public — anyone may list them. + Some("LIST") | None => list(me, from, PUBLIC, false, ctx, db), + // Oper bulletins are for operators only. + Some("OLIST") => list(me, from, OPER, true, ctx, db), + Some("HELP") => help(me, from, ctx), + Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02LIST\x02 or \x02HELP\x02.")), + } + } +} + +fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) { + ctx.notice(me, from.uid, "InfoServ holds the network's information bulletins. \x02LIST\x02 shows the public ones. Operators: \x02POST\x02 / \x02DEL\x02 (public, shown on connect), \x02OPOST\x02 / \x02OLIST\x02 / \x02ODEL\x02 (oper-only, shown on login)."); +} + +fn post(me: &str, from: &Sender, kind: &str, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !from.privs.has(Priv::Admin) { + ctx.notice(me, from.uid, "Access denied — posting bulletins is for services operators."); + return; + } + let message = rest.join(" "); + if message.trim().is_empty() { + ctx.notice(me, from.uid, format!("Syntax: {} ", if kind == OPER { "OPOST" } else { "POST" })); + return; + } + let setter = from.account.unwrap_or(from.nick); + db.news_add(kind, &message, setter); + let which = if kind == OPER { "oper" } else { "public" }; + ctx.notice(me, from.uid, format!("Added a \x02{which}\x02 bulletin.")); +} + +fn del(me: &str, from: &Sender, kind: &str, num: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !from.privs.has(Priv::Admin) { + ctx.notice(me, from.uid, "Access denied — that command is for services operators."); + return; + } + let which = if kind == OPER { "oper" } else { "public" }; + let Some(n) = num.and_then(|n| n.parse::().ok()) else { + ctx.notice(me, from.uid, format!("Syntax: {} ", if kind == OPER { "ODEL" } else { "DEL" })); + return; + }; + match db.news(kind).get(n.wrapping_sub(1)) { + Some(item) if n >= 1 => { + db.news_del(item.id); + ctx.notice(me, from.uid, format!("Removed \x02{which}\x02 bulletin \x02{n}\x02.")); + } + _ => ctx.notice(me, from.uid, format!("There's no \x02{which}\x02 bulletin \x02{n}\x02.")), + } +} + +fn list(me: &str, from: &Sender, kind: &str, oper_only: bool, ctx: &mut ServiceCtx, db: &mut dyn Store) { + if oper_only && !from.privs.any() { + ctx.notice(me, from.uid, "Access denied — oper bulletins are for services operators."); + return; + } + let items = db.news(kind); + let which = if kind == OPER { "oper" } else { "public" }; + if items.is_empty() { + ctx.notice(me, from.uid, format!("There are no {which} bulletins.")); + return; + } + for (i, item) in items.iter().enumerate() { + ctx.notice(me, from.uid, format!("{}. {} — by {}", i + 1, item.text, item.setter)); + } + ctx.notice(me, from.uid, format!("End of {which} bulletins ({} shown).", items.len())); +} diff --git a/operserv/src/lib.rs b/operserv/src/lib.rs index aabebbb..ceb27ef 100644 --- a/operserv/src/lib.rs +++ b/operserv/src/lib.rs @@ -23,8 +23,6 @@ mod stats; mod svs; #[path = "info.rs"] mod info; -#[path = "news.rs"] -mod news; #[path = "oper.rs"] mod oper; #[path = "session.rs"] @@ -75,7 +73,6 @@ impl Service for OperServ { Some(cmd) if cmd.eq_ignore_ascii_case("SVSNICK") => svs::nick(me, from, args, ctx, net), Some(cmd) if cmd.eq_ignore_ascii_case("SVSJOIN") => svs::join(me, from, args, ctx, net), Some(cmd) if cmd.eq_ignore_ascii_case("INFO") => info::handle(me, from, args, ctx, db), - Some(cmd) if cmd.eq_ignore_ascii_case("NEWS") => news::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("OPER") => oper::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("SESSION") => session::handle_session(me, from, args, ctx, net), Some(cmd) if cmd.eq_ignore_ascii_case("EXCEPTION") => session::handle_exception(me, from, args, ctx, db), @@ -91,5 +88,5 @@ impl Service for OperServ { } fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) { - ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02SHUN\x02 ADD|DEL|LIST (silence a host), \x02CBAN\x02 ADD|DEL|LIST (ban a channel name), \x02CHANKILL\x02 <#chan> (clear a channel), \x02GLOBAL\x02 (announce to everyone), \x02KILL\x02 [reason] (disconnect a user), \x02KICK\x02 <#chan> [reason], \x02MODE\x02 <#chan> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 , \x02SVSJOIN\x02 <#chan> [key], \x02INFO\x02 ADD|DEL (staff notes), \x02NEWS\x02 ADD|DEL|LIST (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 | DEL | LIST, \x02LOGSEARCH\x02 [pattern] (search the action log), \x02DEFCON\x02 [1-5] (network defence level)."); + ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02SHUN\x02 ADD|DEL|LIST (silence a host), \x02CBAN\x02 ADD|DEL|LIST (ban a channel name), \x02CHANKILL\x02 <#chan> (clear a channel), \x02GLOBAL\x02 (announce to everyone), \x02KILL\x02 [reason] (disconnect a user), \x02KICK\x02 <#chan> [reason], \x02MODE\x02 <#chan> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 , \x02SVSJOIN\x02 <#chan> [key], \x02INFO\x02 ADD|DEL (staff notes — bulletins are on \x02InfoServ\x02), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 | DEL | LIST, \x02LOGSEARCH\x02 [pattern] (search the action log), \x02DEFCON\x02 [1-5] (network defence level)."); } diff --git a/operserv/src/news.rs b/operserv/src/news.rs deleted file mode 100644 index 0054c2c..0000000 --- a/operserv/src/news.rs +++ /dev/null @@ -1,72 +0,0 @@ -use fedserv_api::{Priv, Sender, ServiceCtx, Store}; - -// NEWS ADD | NEWS DEL | NEWS LIST -// [LOGON|OPER]: manage the announcements shown to users on connect (logon) and -// to operators on login (oper). Admin-only. -pub fn handle(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 — NEWS needs the \x02admin\x02 privilege."); - return; - } - match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { - Some("ADD") => add(me, from, args.get(2).copied(), args.get(3..).unwrap_or(&[]), ctx, db), - Some("DEL") | Some("REMOVE") => del(me, from, args.get(2).copied(), args.get(3).copied(), ctx, db), - Some("LIST") | Some("VIEW") => list(me, from, args.get(2).copied(), ctx, db), - _ => ctx.notice(me, from.uid, "Syntax: NEWS ADD | NEWS DEL | NEWS LIST [LOGON|OPER]"), - } -} - -fn add(me: &str, from: &Sender, kind: Option<&str>, text: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { - let (Some(kind), false) = (kind.and_then(parse_kind), text.is_empty()) else { - ctx.notice(me, from.uid, "Syntax: NEWS ADD "); - return; - }; - let setter = from.account.unwrap_or(from.nick); - db.news_add(kind, &text.join(" "), setter); - ctx.notice(me, from.uid, format!("Added a \x02{kind}\x02 news item.")); -} - -fn del(me: &str, from: &Sender, kind: Option<&str>, num: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { - let (Some(kind), Some(n)) = (kind.and_then(parse_kind), num.and_then(|n| n.parse::().ok())) else { - ctx.notice(me, from.uid, "Syntax: NEWS DEL "); - return; - }; - match db.news(kind).get(n.wrapping_sub(1)) { - Some(item) if n >= 1 => { - db.news_del(item.id); - ctx.notice(me, from.uid, format!("Removed \x02{kind}\x02 news item \x02{n}\x02.")); - } - _ => ctx.notice(me, from.uid, format!("There's no \x02{kind}\x02 news item \x02{n}\x02.")), - } -} - -fn list(me: &str, from: &Sender, kind: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { - let kinds: &[&str] = match kind.map(|k| k.to_ascii_uppercase()) { - Some(k) if k == "LOGON" => &["logon"], - Some(k) if k == "OPER" => &["oper"], - Some(_) => { - ctx.notice(me, from.uid, "Kind must be \x02LOGON\x02 or \x02OPER\x02."); - return; - } - None => &["logon", "oper"], - }; - let mut any = false; - for k in kinds { - let items = db.news(k); - for (i, item) in items.iter().enumerate() { - any = true; - ctx.notice(me, from.uid, format!("{}. [\x02{k}\x02] {} — by {}", i + 1, item.text, item.setter)); - } - } - if !any { - ctx.notice(me, from.uid, "No news items."); - } -} - -fn parse_kind(s: &str) -> Option<&'static str> { - match s.to_ascii_uppercase().as_str() { - "LOGON" => Some("logon"), - "OPER" => Some("oper"), - _ => None, - } -} diff --git a/src/config.rs b/src/config.rs index 987bb07..6b74c29 100644 --- a/src/config.rs +++ b/src/config.rs @@ -133,7 +133,7 @@ impl Default for Modules { } fn default_services() -> Vec { - vec!["nickserv".to_string(), "chanserv".to_string(), "botserv".to_string(), "memoserv".to_string(), "statserv".to_string(), "hostserv".to_string(), "operserv".to_string()] + vec!["nickserv".to_string(), "chanserv".to_string(), "botserv".to_string(), "memoserv".to_string(), "statserv".to_string(), "hostserv".to_string(), "operserv".to_string(), "infoserv".to_string()] } #[derive(Debug, Deserialize, Clone)] diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 6f28f0d..39115f1 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -4565,12 +4565,12 @@ mod tests { assert!(denied(&os(&mut e, "000AAAAAP", "STATS")), "expired grant confers nothing"); } - // OperServ NEWS: logon news greets everyone on connect, oper news greets an - // operator on login; ADD/DEL/LIST are admin-only. + // InfoServ bulletins over the shared news store: public bulletins greet + // everyone on connect, oper bulletins greet an operator on login. #[test] - fn operserv_news_logon_and_oper() { - use fedserv_operserv::OperServ; - let path = std::env::temp_dir().join("fedserv-osnews.jsonl"); + fn infoserv_bulletins_public_and_oper() { + use fedserv_infoserv::InfoServ; + let path = std::env::temp_dir().join("fedserv-infoserv.jsonl"); let _ = std::fs::remove_file(&path); let mut db = Db::open(&path, "42S"); db.scram_iterations = 4096; @@ -4580,7 +4580,7 @@ mod tests { let mut e = Engine::new( vec![ Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), - Box::new(OperServ { uid: "42SAAAAAH".into() }), + Box::new(InfoServ { uid: "42SAAAAAJ".into() }), ], db, ); @@ -4591,42 +4591,42 @@ mod tests { e.set_opers(opers); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); e.set_irc_out(tx); - let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() }); + let is = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAJ".into(), text: t.into() }); let has = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle))); e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() , ip: "0.0.0.0".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); - os(&mut e, "000AAAAAS", "NEWS ADD LOGON Welcome to the network"); - os(&mut e, "000AAAAAS", "NEWS ADD OPER Staff meeting at 5"); + is(&mut e, "000AAAAAS", "POST Welcome to the network"); + is(&mut e, "000AAAAAS", "OPOST Staff meeting at 5"); - // A new user is greeted with the logon news on connect, not the oper news. + // A new user is greeted with the public bulletin on connect, not the oper one. let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAP".into(), nick: "plain".into(), host: "h".into() , ip: "0.0.0.0".into() }); - assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAP" && text.contains("Welcome to the network"))), "logon news on connect: {out:?}"); - assert!(!has(&out, "Staff meeting"), "a plain user doesn't get oper news"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "000AAAAAP" && text.contains("Welcome to the network"))), "public bulletin on connect: {out:?}"); + assert!(!has(&out, "Staff meeting"), "a plain user doesn't get oper bulletins"); - // An operator logging in is shown the oper news (over the outbound path). + // An operator logging in is shown the oper bulletin (over the outbound path). e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() , ip: "0.0.0.0".into() }); - while rx.try_recv().is_ok() {} // drain the connect's logon news + while rx.try_recv().is_ok() {} // drain the connect's public bulletin e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); - let mut oper_news = false; + let mut oper_bulletin = false; while let Ok(a) = rx.try_recv() { if let NetAction::Notice { to, text, .. } = a { if to == "000AAAAAB" && text.contains("Staff meeting at 5") { - oper_news = true; + oper_bulletin = true; } } } - assert!(oper_news, "oper news shown to an operator on login"); + assert!(oper_bulletin, "oper bulletin shown to an operator on login"); - // LIST shows both; DEL removes the logon item so a later connect is quiet. - assert!(has(&os(&mut e, "000AAAAAS", "NEWS LIST"), "Welcome to the network"), "list shows logon"); - os(&mut e, "000AAAAAS", "NEWS DEL LOGON 1"); + // LIST shows the public one (to anyone); DEL removes it so a later connect is quiet. + assert!(has(&is(&mut e, "000AAAAAP", "LIST"), "Welcome to the network"), "anyone can list public bulletins"); + is(&mut e, "000AAAAAS", "DEL 1"); let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAQ".into(), nick: "late".into(), host: "h".into() , ip: "0.0.0.0".into() }); - assert!(!has(&out, "Welcome to the network"), "logon news gone after DEL"); + assert!(!has(&out, "Welcome to the network"), "bulletin gone after DEL"); - // A non-admin (the unidentified plain user) can't manage news. - assert!(has(&os(&mut e, "000AAAAAP", "NEWS ADD LOGON sneaky"), "Access denied"), "non-admin refused"); + // A non-admin can't post. + assert!(has(&is(&mut e, "000AAAAAP", "POST sneaky"), "Access denied"), "non-admin refused"); } // OperServ INFO staff notes: an admin annotates an account/channel; the note diff --git a/src/main.rs b/src/main.rs index ac7c8bf..d5f197f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ use fedserv_statserv::StatServ; use fedserv_hostserv::HostServ; use fedserv_operserv::OperServ; use fedserv_diceserv::DiceServ; +use fedserv_infoserv::InfoServ; use fedserv_example::ExampleServ; use fedserv_inspircd::InspIrcd; use fedserv_nickserv::NickServ; @@ -95,6 +96,11 @@ async fn main() -> Result<()> { uid: format!("{}AAAAAI", cfg.server.sid), })); } + if enabled("infoserv") { + services.push(Box::new(InfoServ { + uid: format!("{}AAAAAJ", cfg.server.sid), + })); + } if enabled("example") { services.push(Box::new(ExampleServ { uid: format!("{}AAAAAC", cfg.server.sid),