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:
parent
51d4ebf789
commit
e45eab2cd6
6 changed files with 281 additions and 26 deletions
|
|
@ -457,6 +457,15 @@ pub struct IgnoreView {
|
||||||
pub expires: Option<u64>,
|
pub expires: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A news item held by OperServ (its kind is implied by which list it came from).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NewsView {
|
||||||
|
pub id: u64,
|
||||||
|
pub text: String,
|
||||||
|
pub setter: String,
|
||||||
|
pub ts: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BotView {
|
pub struct BotView {
|
||||||
pub nick: String,
|
pub nick: String,
|
||||||
|
|
@ -718,6 +727,10 @@ pub trait Store {
|
||||||
fn account_note(&self, account: &str) -> Option<String>;
|
fn account_note(&self, account: &str) -> Option<String>;
|
||||||
fn set_channel_note(&mut self, channel: &str, note: Option<String>) -> bool;
|
fn set_channel_note(&mut self, channel: &str, note: Option<String>) -> bool;
|
||||||
fn channel_note(&self, channel: &str) -> Option<String>;
|
fn channel_note(&self, channel: &str) -> Option<String>;
|
||||||
|
// News items shown on connect (logon) / login (oper), oper-only to manage.
|
||||||
|
fn news_add(&mut self, kind: &str, text: &str, setter: &str) -> u64;
|
||||||
|
fn news_del(&mut self, id: u64) -> bool;
|
||||||
|
fn news(&self, kind: &str) -> Vec<NewsView>;
|
||||||
fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError>;
|
fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError>;
|
||||||
fn drop_channel(&mut self, name: &str) -> Result<(), ChanError>;
|
fn drop_channel(&mut self, name: &str) -> Result<(), ChanError>;
|
||||||
fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError>;
|
fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError>;
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ mod stats;
|
||||||
mod svs;
|
mod svs;
|
||||||
#[path = "info.rs"]
|
#[path = "info.rs"]
|
||||||
mod info;
|
mod info;
|
||||||
|
#[path = "news.rs"]
|
||||||
|
mod news;
|
||||||
|
|
||||||
pub struct OperServ {
|
pub struct OperServ {
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
|
|
@ -58,6 +60,7 @@ 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("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("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("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("HELP") => help(me, from, ctx),
|
Some(cmd) if cmd.eq_ignore_ascii_case("HELP") => help(me, from, ctx),
|
||||||
None => 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 \x02HELP\x02.")),
|
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02HELP\x02.")),
|
||||||
|
|
@ -66,5 +69,5 @@ impl Service for OperServ {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
|
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), \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).");
|
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), \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).");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
72
operserv/src/news.rs
Normal file
72
operserv/src/news.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/engine/db.rs
123
src/engine/db.rs
|
|
@ -30,7 +30,7 @@ use super::scram::{self, Hash};
|
||||||
// fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
|
// fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
|
||||||
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
|
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
|
||||||
pub use fedserv_api::{
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -185,6 +185,9 @@ pub enum Event {
|
||||||
// A staff note set or cleared (OperServ INFO).
|
// A staff note set or cleared (OperServ INFO).
|
||||||
AccountOperNoteSet { account: String, note: Option<String> },
|
AccountOperNoteSet { account: String, note: Option<String> },
|
||||||
ChannelOperNoteSet { channel: 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 bans (OperServ AKILL / SQLINE). Global: a ban covers the whole
|
||||||
// network, so every node holds the list and re-applies it at burst. `kind`
|
// 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.
|
// is the ircd X-line type and defaults to "G" for records predating it.
|
||||||
|
|
@ -243,7 +246,9 @@ impl Event {
|
||||||
| Event::AccountExpiryWarned { .. }
|
| Event::AccountExpiryWarned { .. }
|
||||||
| Event::AccountOperNoteSet { .. }
|
| Event::AccountOperNoteSet { .. }
|
||||||
| Event::AkillAdded { .. }
|
| Event::AkillAdded { .. }
|
||||||
| Event::AkillRemoved { .. } => Scope::Global,
|
| Event::AkillRemoved { .. }
|
||||||
|
| Event::NewsAdded { .. }
|
||||||
|
| Event::NewsDeleted { .. } => Scope::Global,
|
||||||
Event::ChannelRegistered { .. }
|
Event::ChannelRegistered { .. }
|
||||||
| Event::ChannelDropped { .. }
|
| Event::ChannelDropped { .. }
|
||||||
| Event::ChannelMlock { .. }
|
| Event::ChannelMlock { .. }
|
||||||
|
|
@ -333,6 +338,29 @@ pub struct Ignore {
|
||||||
pub expires: Option<u64>,
|
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).
|
// A memo left for an account (MemoServ).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Memo {
|
pub struct Memo {
|
||||||
|
|
@ -900,8 +928,8 @@ pub struct Db {
|
||||||
// HostServ node config: the self-serve offer menu, the forbidden-pattern
|
// HostServ node config: the self-serve offer menu, the forbidden-pattern
|
||||||
// blocklist, and the auto-vhost template.
|
// blocklist, and the auto-vhost template.
|
||||||
host_cfg: HostConfig,
|
host_cfg: HostConfig,
|
||||||
// Network bans (OperServ AKILL), in insertion order.
|
// Network-wide replicated lists: bans (AKILL/SQLINE) and news.
|
||||||
akills: Vec<Akill>,
|
net: NetData,
|
||||||
// Services ignores (OperServ IGNORE), node-local and in-memory.
|
// Services ignores (OperServ IGNORE), node-local and in-memory.
|
||||||
ignores: Vec<Ignore>,
|
ignores: Vec<Ignore>,
|
||||||
}
|
}
|
||||||
|
|
@ -954,12 +982,12 @@ impl Db {
|
||||||
let mut grouped = HashMap::new();
|
let mut grouped = HashMap::new();
|
||||||
let mut bots = HashMap::new();
|
let mut bots = HashMap::new();
|
||||||
let mut host_cfg = HostConfig::default();
|
let mut host_cfg = HostConfig::default();
|
||||||
let mut akills = Vec::new();
|
let mut net = NetData::default();
|
||||||
for event in events {
|
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");
|
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
|
/// Fold an entry authored by another node into the store — the services-side
|
||||||
|
|
@ -975,7 +1003,7 @@ impl Db {
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(event) = self.log.ingest(entry)? {
|
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 {
|
if let Some((name, prev_home)) = watched {
|
||||||
match (prev_home, self.account(&name).map(|c| c.home.clone())) {
|
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.
|
// Compaction is a good moment to forget akills that have already expired.
|
||||||
let now = now();
|
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 });
|
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)?;
|
self.log.compact(snapshot)?;
|
||||||
tracing::info!(before, after = self.log.len(), "compacted event log");
|
tracing::info!(before, after = self.log.len(), "compacted event log");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -1822,37 +1853,67 @@ impl Db {
|
||||||
/// Add (or refresh) a `kind` network ban. Returns whether it was newly added.
|
/// 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> {
|
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 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
|
self.log
|
||||||
.append(Event::AkillAdded { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires })
|
.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)?;
|
.map_err(|_| RegError::Internal)?;
|
||||||
self.akills.retain(|a| !same(a));
|
self.net.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.push(Akill { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now(), expires });
|
||||||
Ok(fresh)
|
Ok(fresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lift a `kind` network ban. Returns whether a live one was removed.
|
/// 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> {
|
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 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 {
|
if !existed {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
self.log.append(Event::AkillRemoved { kind: kind.to_string(), mask: mask.to_string() }).map_err(|_| RegError::Internal)?;
|
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)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The live network bans (expired ones hidden lazily), oldest first.
|
/// The live network bans (expired ones hidden lazily), oldest first.
|
||||||
pub fn akills(&self) -> Vec<AkillView> {
|
pub fn akills(&self) -> Vec<AkillView> {
|
||||||
let now = now();
|
let now = now();
|
||||||
self.akills
|
self.net.akills
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|a| a.expires.is_none_or(|e| e > now))
|
.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 })
|
.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()
|
.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.
|
/// 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>) {
|
pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>) {
|
||||||
self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask));
|
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
|
// Fold one event into the store. Shared by log replay (`open`) and gossip
|
||||||
// ingest, so both routes reconstruct identical state.
|
// 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 {
|
match event {
|
||||||
Event::AccountRegistered(a) => {
|
Event::AccountRegistered(a) => {
|
||||||
// Resolve a concurrent registration of the same name deterministically:
|
// 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 } => {
|
Event::AkillAdded { kind, mask, setter, reason, ts, expires } => {
|
||||||
// Keyed by (kind, mask), case-insensitive: a re-add refreshes in
|
// Keyed by (kind, mask), case-insensitive: a re-add refreshes in
|
||||||
// place, so replaying over a snapshot stays idempotent.
|
// place, so replaying over a snapshot stays idempotent.
|
||||||
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)));
|
||||||
akills.push(Akill { kind, mask, setter, reason, ts, expires });
|
net.akills.push(Akill { kind, mask, setter, reason, ts, expires });
|
||||||
}
|
}
|
||||||
Event::AkillRemoved { kind, mask } => {
|
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 } => {
|
Event::AccountExpiryWarned { account } => {
|
||||||
if let Some(a) = accounts.get_mut(&key(&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;
|
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> {
|
fn channel_note(&self, channel: &str) -> Option<String> {
|
||||||
Db::channel_note(self, channel)
|
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> {
|
fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError> {
|
||||||
Db::register_channel(self, name, founder)
|
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,
|
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 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());
|
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 ak, Event::AccountRegistered(Box::new(first.clone())));
|
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 ak, Event::AccountRegistered(Box::new(second.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()
|
acc["alice"].password_hash.clone()
|
||||||
};
|
};
|
||||||
// Earlier registration wins, regardless of which claim applies first.
|
// Earlier registration wins, regardless of which claim applies first.
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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.
|
// from the services server. Used for actions that aren't tied to a command.
|
||||||
fn audit(&self, text: String) {
|
fn audit(&self, text: String) {
|
||||||
|
|
@ -700,8 +713,9 @@ impl Engine {
|
||||||
let evout = match event {
|
let evout = match event {
|
||||||
NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }],
|
NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }],
|
||||||
NetEvent::UserConnect { uid, nick, host } => {
|
NetEvent::UserConnect { uid, nick, host } => {
|
||||||
self.network.user_connect(uid, nick, host);
|
self.network.user_connect(uid.clone(), nick, host);
|
||||||
Vec::new()
|
// Greet the arriving user with the logon news.
|
||||||
|
self.news_notices("logon", "News", &uid)
|
||||||
}
|
}
|
||||||
NetEvent::NickChange { uid, nick } => {
|
NetEvent::NickChange { uid, nick } => {
|
||||||
self.network.user_nick_change(&uid, nick);
|
self.network.user_nick_change(&uid, nick);
|
||||||
|
|
@ -888,6 +902,12 @@ impl Engine {
|
||||||
self.network.set_account(target, value);
|
self.network.set_account(target, value);
|
||||||
// A login is activity: keep the account from expiring.
|
// A login is activity: keep the account from expiring.
|
||||||
self.db.mark_account_seen(value, self.now_secs());
|
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"),
|
Some(_) => format!("set a staff note on \x02{channel}\x02"),
|
||||||
None => format!("cleared the 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 } => {
|
AccountNoExpire { account, on } => {
|
||||||
let verb = if *on { "pinned" } else { "unpinned" };
|
let verb = if *on { "pinned" } else { "unpinned" };
|
||||||
format!("{verb} account \x02{account}\x02 against expiry")
|
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
|
// 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.
|
// shows in that service's INFO to opers only, never to the account owner.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,9 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
|
||||||
| Event::AccountExpiryWarned { .. }
|
| Event::AccountExpiryWarned { .. }
|
||||||
| Event::ChannelExpiryWarned { .. }
|
| Event::ChannelExpiryWarned { .. }
|
||||||
| Event::AccountOperNoteSet { .. }
|
| 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) })
|
Some(ReplicationEvent { origin: entry.origin().to_string(), seq: entry.seq(), lamport: entry.lamport(), kind: Some(kind) })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue