OperServ: NEWS — logon and oper announcements

NEWS ADD <LOGON|OPER> <text> / DEL <LOGON|OPER> <number> / LIST. Logon news
is shown to every user as they connect; oper news is shown to an operator
when they log in. Admin-only to manage. Each item carries a stable id
assigned at add time and embedded in the log, so deletion is order-
independent under replay (DEL by number resolves to that id).

Enabling groundwork: the replicated network-wide lists (bans, now news)
are consolidated into one NetData struct threaded through apply() as a
single parameter — this replaces the akill parameter rather than adding
one, so the fold stays within its argument budget as the lists grow.
This commit is contained in:
Jean Chevronnet 2026-07-14 01:26:12 +00:00
parent 51d4ebf789
commit e45eab2cd6
No known key found for this signature in database
6 changed files with 281 additions and 26 deletions

View file

@ -30,7 +30,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, AkillView, BotView, IgnoreView, MemoView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView,
AccountView, AjoinView, AkillView, BotView, IgnoreView, MemoView, NewsView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -185,6 +185,9 @@ pub enum Event {
// A staff note set or cleared (OperServ INFO).
AccountOperNoteSet { account: String, note: Option<String> },
ChannelOperNoteSet { channel: String, note: Option<String> },
// News items (OperServ NEWS). The stable `id` makes deletion order-independent.
NewsAdded { id: u64, kind: String, text: String, setter: String, ts: u64 },
NewsDeleted { id: u64 },
// Network bans (OperServ AKILL / SQLINE). Global: a ban covers the whole
// network, so every node holds the list and re-applies it at burst. `kind`
// is the ircd X-line type and defaults to "G" for records predating it.
@ -243,7 +246,9 @@ impl Event {
| Event::AccountExpiryWarned { .. }
| Event::AccountOperNoteSet { .. }
| Event::AkillAdded { .. }
| Event::AkillRemoved { .. } => Scope::Global,
| Event::AkillRemoved { .. }
| Event::NewsAdded { .. }
| Event::NewsDeleted { .. } => Scope::Global,
Event::ChannelRegistered { .. }
| Event::ChannelDropped { .. }
| Event::ChannelMlock { .. }
@ -333,6 +338,29 @@ pub struct Ignore {
pub expires: Option<u64>,
}
// A news item (OperServ NEWS): a `kind` ("logon" shown to everyone on connect,
// "oper" shown to operators on login), the text, who set it, and when. Carries a
// stable id (assigned at add time, embedded in the log) so deletion is order-
// independent under replay.
#[derive(Debug, Clone)]
pub struct News {
pub id: u64,
pub kind: String,
pub text: String,
pub setter: String,
pub ts: u64,
}
// The network-wide lists that aren't accounts/channels/grouped/bots: the network
// bans and the news items. Bundled so `apply` threads one parameter for them
// (and stays within its argument budget as more are added).
#[derive(Debug, Clone, Default)]
pub struct NetData {
pub akills: Vec<Akill>,
pub news: Vec<News>,
pub news_seq: u64,
}
// A memo left for an account (MemoServ).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memo {
@ -900,8 +928,8 @@ pub struct Db {
// HostServ node config: the self-serve offer menu, the forbidden-pattern
// blocklist, and the auto-vhost template.
host_cfg: HostConfig,
// Network bans (OperServ AKILL), in insertion order.
akills: Vec<Akill>,
// Network-wide replicated lists: bans (AKILL/SQLINE) and news.
net: NetData,
// Services ignores (OperServ IGNORE), node-local and in-memory.
ignores: Vec<Ignore>,
}
@ -954,12 +982,12 @@ impl Db {
let mut grouped = HashMap::new();
let mut bots = HashMap::new();
let mut host_cfg = HostConfig::default();
let mut akills = Vec::new();
let mut net = NetData::default();
for event in events {
apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut akills, event);
apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, 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(), vhost_req_times: HashMap::new(), bots, host_cfg, akills, ignores: Vec::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(), vhost_req_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new() }
}
/// Fold an entry authored by another node into the store — the services-side
@ -975,7 +1003,7 @@ impl Db {
_ => None,
};
if let Some(event) = self.log.ingest(entry)? {
apply(&mut self.accounts, &mut self.channels, &mut self.grouped, &mut self.bots, &mut self.host_cfg, &mut self.akills, event);
apply(&mut self.accounts, &mut self.channels, &mut self.grouped, &mut self.bots, &mut self.host_cfg, &mut self.net, event);
}
if let Some((name, prev_home)) = watched {
match (prev_home, self.account(&name).map(|c| c.home.clone())) {
@ -1050,9 +1078,12 @@ impl Db {
}
// Compaction is a good moment to forget akills that have already expired.
let now = now();
for a in self.akills.iter().filter(|a| a.expires.is_none_or(|e| e > now)) {
for a in self.net.akills.iter().filter(|a| a.expires.is_none_or(|e| e > now)) {
snapshot.push(Event::AkillAdded { kind: a.kind.clone(), mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires });
}
for n in &self.net.news {
snapshot.push(Event::NewsAdded { id: n.id, kind: n.kind.clone(), text: n.text.clone(), setter: n.setter.clone(), ts: n.ts });
}
self.log.compact(snapshot)?;
tracing::info!(before, after = self.log.len(), "compacted event log");
Ok(())
@ -1822,37 +1853,67 @@ impl Db {
/// Add (or refresh) a `kind` network ban. Returns whether it was newly added.
pub fn akill_add(&mut self, kind: &str, mask: &str, setter: &str, reason: &str, expires: Option<u64>) -> Result<bool, RegError> {
let same = |a: &Akill| a.kind == kind && a.mask.eq_ignore_ascii_case(mask);
let fresh = !self.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now()));
let fresh = !self.net.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now()));
self.log
.append(Event::AkillAdded { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires })
.map_err(|_| RegError::Internal)?;
self.akills.retain(|a| !same(a));
self.akills.push(Akill { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires });
self.net.akills.retain(|a| !same(a));
self.net.akills.push(Akill { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires });
Ok(fresh)
}
/// Lift a `kind` network ban. Returns whether a live one was removed.
pub fn akill_del(&mut self, kind: &str, mask: &str) -> Result<bool, RegError> {
let same = |a: &Akill| a.kind == kind && a.mask.eq_ignore_ascii_case(mask);
let existed = self.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now()));
let existed = self.net.akills.iter().any(|a| same(a) && a.expires.is_none_or(|e| e > now()));
if !existed {
return Ok(false);
}
self.log.append(Event::AkillRemoved { kind: kind.to_string(), mask: mask.to_string() }).map_err(|_| RegError::Internal)?;
self.akills.retain(|a| !same(a));
self.net.akills.retain(|a| !same(a));
Ok(true)
}
/// The live network bans (expired ones hidden lazily), oldest first.
pub fn akills(&self) -> Vec<AkillView> {
let now = now();
self.akills
self.net.akills
.iter()
.filter(|a| a.expires.is_none_or(|e| e > now))
.map(|a| AkillView { kind: a.kind.clone(), mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires })
.collect()
}
/// Add a news item of `kind` ("logon"/"oper"). Returns its stable id.
pub fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64 {
let id = self.net.news_seq;
let ts = now();
let _ = self.log.append(Event::NewsAdded { id, kind: kind.to_string(), text: text.to_string(), setter: setter.to_string(), ts });
self.net.news_seq = id + 1;
self.net.news.push(News { id, kind: kind.to_string(), text: text.to_string(), setter: setter.to_string(), ts });
id
}
/// Delete a news item by id. Returns whether one existed.
pub fn news_del(&mut self, id: u64) -> bool {
let existed = self.net.news.iter().any(|n| n.id == id);
if existed {
let _ = self.log.append(Event::NewsDeleted { id });
self.net.news.retain(|n| n.id != id);
}
existed
}
/// The news items of `kind`, oldest first.
pub fn news(&self, kind: &str) -> Vec<NewsView> {
self.net
.news
.iter()
.filter(|n| n.kind == kind)
.map(|n| NewsView { id: n.id, text: n.text.clone(), setter: n.setter.clone(), ts: n.ts })
.collect()
}
/// Add a services ignore, replacing any existing entry for the same mask.
pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>) {
self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask));
@ -2535,7 +2596,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<String, Account>, channels: &mut HashMap<String, ChannelInfo>, grouped: &mut HashMap<String, String>, bots: &mut HashMap<String, Bot>, host_cfg: &mut HostConfig, akills: &mut Vec<Akill>, event: Event) {
fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String, ChannelInfo>, grouped: &mut HashMap<String, String>, bots: &mut HashMap<String, Bot>, host_cfg: &mut HostConfig, net: &mut NetData, event: Event) {
match event {
Event::AccountRegistered(a) => {
// Resolve a concurrent registration of the same name deterministically:
@ -2796,11 +2857,11 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
Event::AkillAdded { kind, mask, setter, reason, ts, expires } => {
// Keyed by (kind, mask), case-insensitive: a re-add refreshes in
// place, so replaying over a snapshot stays idempotent.
akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask)));
akills.push(Akill { kind, mask, setter, reason, ts, expires });
net.akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask)));
net.akills.push(Akill { kind, mask, setter, reason, ts, expires });
}
Event::AkillRemoved { kind, mask } => {
akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask)));
net.akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask)));
}
Event::AccountExpiryWarned { account } => {
if let Some(a) = accounts.get_mut(&key(&account)) {
@ -2822,6 +2883,15 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
c.oper_note = note;
}
}
Event::NewsAdded { id, kind, text, setter, ts } => {
net.news_seq = net.news_seq.max(id + 1);
if !net.news.iter().any(|n| n.id == id) {
net.news.push(News { id, kind, text, setter, ts });
}
}
Event::NewsDeleted { id } => {
net.news.retain(|n| n.id != id);
}
}
}
@ -3084,6 +3154,15 @@ impl Store for Db {
fn channel_note(&self, channel: &str) -> Option<String> {
Db::channel_note(self, channel)
}
fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64 {
Db::news_add(self, kind, text, setter)
}
fn news_del(&mut self, id: u64) -> bool {
Db::news_del(self, id)
}
fn news(&self, kind: &str) -> Vec<NewsView> {
Db::news(self, kind)
}
fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError> {
Db::register_channel(self, name, founder)
}
@ -3287,9 +3366,9 @@ mod tests {
ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: ts, noexpire: false, expiry_warned: false, oper_note: None,
};
let converge = |first: &Account, second: &Account| {
let (mut acc, mut ch, mut gr, mut bo, mut hc, mut ak) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), Vec::new());
apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut ak, Event::AccountRegistered(Box::new(first.clone())));
apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut ak, Event::AccountRegistered(Box::new(second.clone())));
let (mut acc, mut ch, mut gr, mut bo, mut hc, mut nd) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), NetData::default());
apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut nd, Event::AccountRegistered(Box::new(first.clone())));
apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut nd, Event::AccountRegistered(Box::new(second.clone())));
acc["alice"].password_hash.clone()
};
// Earlier registration wins, regardless of which claim applies first.

View file

@ -596,6 +596,19 @@ impl Engine {
}
}
// The news items of `kind` as server-sourced notices to `uid`, each tagged
// with `label` so it reads as an announcement. Empty if we have no SID yet.
fn news_notices(&self, kind: &str, label: &str, uid: &str) -> Vec<NetAction> {
if self.sid.is_empty() {
return Vec::new();
}
self.db
.news(kind)
.into_iter()
.map(|n| NetAction::Notice { from: self.sid.clone(), to: uid.to_string(), text: format!("[\x02{label}\x02] {}", n.text) })
.collect()
}
// Announce a line to the staff audit channel, if one is configured, sourced
// from the services server. Used for actions that aren't tied to a command.
fn audit(&self, text: String) {
@ -700,8 +713,9 @@ impl Engine {
let evout = match event {
NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }],
NetEvent::UserConnect { uid, nick, host } => {
self.network.user_connect(uid, nick, host);
Vec::new()
self.network.user_connect(uid.clone(), nick, host);
// Greet the arriving user with the logon news.
self.news_notices("logon", "News", &uid)
}
NetEvent::NickChange { uid, nick } => {
self.network.user_nick_change(&uid, nick);
@ -888,6 +902,12 @@ impl Engine {
self.network.set_account(target, value);
// A login is activity: keep the account from expiring.
self.db.mark_account_seen(value, self.now_secs());
// An operator logging in is shown the staff (oper) news.
if self.oper_privs(value).any() {
for note in self.news_notices("oper", "Staff News", target) {
self.emit_irc(note);
}
}
}
}
}
@ -1582,6 +1602,8 @@ fn audit_summary(event: &db::Event) -> Option<String> {
Some(_) => format!("set a staff note on \x02{channel}\x02"),
None => format!("cleared the staff note on \x02{channel}\x02"),
},
NewsAdded { kind, .. } => format!("added a \x02{kind}\x02 news item"),
NewsDeleted { .. } => "removed a news item".to_string(),
AccountNoExpire { account, on } => {
let verb = if *on { "pinned" } else { "unpinned" };
format!("{verb} account \x02{account}\x02 against expiry")
@ -4024,6 +4046,70 @@ mod tests {
}
}
// OperServ NEWS: logon news greets everyone on connect, oper news greets an
// operator on login; ADD/DEL/LIST are admin-only.
#[test]
fn operserv_news_logon_and_oper() {
use fedserv_operserv::OperServ;
let path = std::env::temp_dir().join("fedserv-osnews.jsonl");
let _ = std::fs::remove_file(&path);
let mut db = Db::open(&path, "42S");
db.scram_iterations = 4096;
db.register("staff", "password1", None).unwrap();
db.register("boss", "password1", None).unwrap();
db.register("plain", "password1", None).unwrap();
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() }),
],
db,
);
e.set_sid("42S".into());
let mut opers = std::collections::HashMap::new();
opers.insert("staff".to_string(), Privs::default().with(fedserv_api::Priv::Admin));
opers.insert("boss".to_string(), Privs::default().with(fedserv_api::Priv::Admin));
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 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() });
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");
// A new user is greeted with the logon news on connect, not the oper news.
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAP".into(), nick: "plain".into(), host: "h".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");
// An operator logging in is shown the oper news (over the outbound path).
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() });
while rx.try_recv().is_ok() {} // drain the connect's logon news
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
let mut oper_news = 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;
}
}
}
assert!(oper_news, "oper news 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");
let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAQ".into(), nick: "late".into(), host: "h".into() });
assert!(!has(&out, "Welcome to the network"), "logon news 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");
}
// OperServ INFO staff notes: an admin annotates an account/channel; the note
// shows in that service's INFO to opers only, never to the account owner.
#[test]

View file

@ -170,7 +170,9 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
| Event::AccountExpiryWarned { .. }
| Event::ChannelExpiryWarned { .. }
| Event::AccountOperNoteSet { .. }
| Event::ChannelOperNoteSet { .. } => return None,
| Event::ChannelOperNoteSet { .. }
| Event::NewsAdded { .. }
| Event::NewsDeleted { .. } => return None,
};
Some(ReplicationEvent { origin: entry.origin().to_string(), seq: entry.seq(), lamport: entry.lamport(), kind: Some(kind) })
}