Add InfoServ, unifying network bulletins onto the news store

InfoServ becomes the dedicated home for network information bulletins,
backed by the same event-sourced news store OperServ NEWS used — one place
to post, not two. POST/LIST/DEL manage public bulletins (shown to every
user on connect); OPOST/OLIST/ODEL the oper ones (shown to operators on
login). Anyone may LIST the public bulletins; posting/removing is admin-
only. The engine's existing connect/login display hooks render them
unchanged. The OperServ NEWS command is retired accordingly (the news
events and the display path stay). Default service.
This commit is contained in:
Jean Chevronnet 2026-07-14 03:56:09 +00:00
parent 1e8a5eab36
commit 8d495a948e
No known key found for this signature in database
9 changed files with 152 additions and 101 deletions

8
Cargo.lock generated
View file

@ -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"

View file

@ -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"

8
infoserv/Cargo.toml Normal file
View file

@ -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" }

103
infoserv/src/lib.rs Normal file
View file

@ -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 <message> / \x02DEL\x02 <number> (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: {} <message>", 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::<usize>().ok()) else {
ctx.notice(me, from.uid, format!("Syntax: {} <number>", 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()));
}

View file

@ -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 <message> (announce to everyone), \x02KILL\x02 <nick> [reason] (disconnect a user), \x02KICK\x02 <#chan> <nick> [reason], \x02MODE\x02 <#chan> <modes> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 <nick> <newnick>, \x02SVSJOIN\x02 <nick> <#chan> [key], \x02INFO\x02 ADD|DEL <target> (staff notes), \x02NEWS\x02 ADD|DEL|LIST <LOGON|OPER> (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 <server> | 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 <message> (announce to everyone), \x02KILL\x02 <nick> [reason] (disconnect a user), \x02KICK\x02 <#chan> <nick> [reason], \x02MODE\x02 <#chan> <modes> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 <nick> <newnick>, \x02SVSJOIN\x02 <nick> <#chan> [key], \x02INFO\x02 ADD|DEL <target> (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 <server> | DEL | LIST, \x02LOGSEARCH\x02 [pattern] (search the action log), \x02DEFCON\x02 [1-5] (network defence level).");
}

View file

@ -1,72 +0,0 @@
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
// NEWS ADD <LOGON|OPER> <text> | NEWS DEL <LOGON|OPER> <number> | 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 <LOGON|OPER> <text> | NEWS DEL <LOGON|OPER> <number> | 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 <LOGON|OPER> <text>");
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::<usize>().ok())) else {
ctx.notice(me, from.uid, "Syntax: NEWS DEL <LOGON|OPER> <number>");
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,
}
}

View file

@ -133,7 +133,7 @@ impl Default for Modules {
}
fn default_services() -> Vec<String> {
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)]

View file

@ -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

View file

@ -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),