operserv: add NOTIFY oper watch list emitting matched-user events to the staff feed

This commit is contained in:
Jean Chevronnet 2026-07-18 16:16:06 +00:00
parent 86b1aae686
commit c415117ec5
No known key found for this signature in database
11 changed files with 433 additions and 10 deletions

View file

@ -1480,6 +1480,19 @@ pub struct ForbidView {
pub ts: u64, pub ts: u64,
} }
// One OperServ NOTIFY watch entry: a mask opers want events flagged for. `flags`
// is a subset of "cdjpntsS" (connect/disconnect/join/part/nick/topic/services).
// `expires` is None for a permanent watch.
#[derive(Debug, Clone)]
pub struct NotifyView {
pub mask: String,
pub flags: String,
pub reason: String,
pub setter: String,
pub ts: u64,
pub expires: Option<u64>,
}
// A services ignore held by OperServ. // A services ignore held by OperServ.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct IgnoreView { pub struct IgnoreView {
@ -1870,6 +1883,15 @@ pub trait Store {
fn forbid_del(&mut self, kind: ForbidKind, mask: &str) -> Result<bool, RegError>; fn forbid_del(&mut self, kind: ForbidKind, mask: &str) -> Result<bool, RegError>;
fn forbids(&self) -> Vec<ForbidView>; fn forbids(&self) -> Vec<ForbidView>;
fn is_forbidden(&self, kind: ForbidKind, name: &str) -> Option<String>; fn is_forbidden(&self, kind: ForbidKind, name: &str) -> Option<String>;
// OperServ NOTIFY (oper watch list). `notify_flags` returns the union of the
// flag letters of every live entry that matches the given user and/or channel,
// for the engine to test against a specific event.
fn notify_add(&mut self, mask: &str, flags: &str, reason: &str, setter: &str, expires: Option<u64>) -> Result<bool, RegError>;
fn notify_del(&mut self, mask: &str) -> Result<bool, RegError>;
fn notify_clear(&mut self) -> Result<usize, RegError>;
fn notifies(&self) -> Vec<NotifyView>;
fn any_notifies(&self) -> bool;
fn notify_flags(&self, target: Option<&BanTarget>, chan: Option<&str>) -> String;
// Services ignores (node-local, oper-only). // Services ignores (node-local, oper-only).
fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>); fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>);
fn ignore_del(&mut self, mask: &str) -> bool; fn ignore_del(&mut self, mask: &str) -> bool;

View file

@ -11,6 +11,7 @@ mod spamfilter;
mod redact; mod redact;
#[path = "forbid.rs"] #[path = "forbid.rs"]
mod forbid; mod forbid;
mod notify;
#[path = "global.rs"] #[path = "global.rs"]
mod global; mod global;
#[path = "kill.rs"] #[path = "kill.rs"]
@ -81,6 +82,7 @@ impl Service for OperServ {
Some(cmd) if cmd.eq_ignore_ascii_case("SPAMFILTER") => spamfilter::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("SPAMFILTER") => spamfilter::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("REDACT") => redact::handle(me, from, args, ctx), Some(cmd) if cmd.eq_ignore_ascii_case("REDACT") => redact::handle(me, from, args, ctx),
Some(cmd) if cmd.eq_ignore_ascii_case("FORBID") => forbid::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("FORBID") => forbid::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("NOTIFY") => notify::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, args, ctx), Some(cmd) if cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, args, ctx),
Some(cmd) if cmd.eq_ignore_ascii_case("KILL") => kill::handle(me, from, args, ctx, net), Some(cmd) if cmd.eq_ignore_ascii_case("KILL") => kill::handle(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("KICK") => kick::handle(me, from, args, ctx, net), Some(cmd) if cmd.eq_ignore_ascii_case("KICK") => kick::handle(me, from, args, ctx, net),
@ -116,6 +118,7 @@ const TOPICS: &[HelpEntry] = &[
HelpEntry { cmd: "SPAMFILTER", summary: "content spam filter (ircd m_filter)", detail: "Syntax: \x02SPAMFILTER ADD <action> [+expiry] <regex> <reason> | SPAMFILTER DEL <regex|number> | SPAMFILTER LIST [pattern]\x02\nMatches message content (a regular expression, e.g. \x02.*free.*bitcoin.*\x02) and acts on it. Actions: gline, zline, block, silent, kill, shun, warn, none. echo persists filters and re-applies them to the ircd at each link. Admin only." }, HelpEntry { cmd: "SPAMFILTER", summary: "content spam filter (ircd m_filter)", detail: "Syntax: \x02SPAMFILTER ADD <action> [+expiry] <regex> <reason> | SPAMFILTER DEL <regex|number> | SPAMFILTER LIST [pattern]\x02\nMatches message content (a regular expression, e.g. \x02.*free.*bitcoin.*\x02) and acts on it. Actions: gline, zline, block, silent, kill, shun, warn, none. echo persists filters and re-applies them to the ircd at each link. Admin only." },
HelpEntry { cmd: "REDACT", summary: "delete a message by its msgid", detail: "Syntax: \x02REDACT <#channel|nick> <msgid> [reason]\x02\nDeletes a specific message (by its IRCv3 \x02msgid\x02, which your client shows) from everyone who received it. draft/message-redaction." }, HelpEntry { cmd: "REDACT", summary: "delete a message by its msgid", detail: "Syntax: \x02REDACT <#channel|nick> <msgid> [reason]\x02\nDeletes a specific message (by its IRCv3 \x02msgid\x02, which your client shows) from everyone who received it. draft/message-redaction." },
HelpEntry { cmd: "FORBID", summary: "ban a nick/chan/email from registration", detail: "Syntax: \x02FORBID ADD <NICK|CHAN|EMAIL> <mask> <reason> | FORBID DEL <NICK|CHAN|EMAIL> <mask> | FORBID LIST\x02\nStops a nick, channel, or email pattern from being registered." }, HelpEntry { cmd: "FORBID", summary: "ban a nick/chan/email from registration", detail: "Syntax: \x02FORBID ADD <NICK|CHAN|EMAIL> <mask> <reason> | FORBID DEL <NICK|CHAN|EMAIL> <mask> | FORBID LIST\x02\nStops a nick, channel, or email pattern from being registered." },
HelpEntry { cmd: "NOTIFY", summary: "watch masks and log their events", detail: "Syntax: \x02NOTIFY ADD +<expiry> <flags|*> <mask> <reason> | NOTIFY DEL <mask|number> | NOTIFY LIST [pattern] | NOTIFY VIEW [pattern] | NOTIFY CLEAR\x02\nWatches a \x02nick!user@host\x02 pattern, bare nick glob, or \x02#channel\x02; matching users have their flagged events announced to the staff feed. Flags: \x02c\x02 connect, \x02d\x02 disconnect, \x02j\x02 join, \x02p\x02 part, \x02n\x02 nick change, \x02t\x02 topic, \x02s\x02 services command, \x02S\x02 services SET (\x02*\x02 for all). Expiry like \x02+30d\x02, or \x02+0\x02 for permanent. Admin only." },
HelpEntry { cmd: "SQLINE", summary: "ban a nick pattern", detail: "Syntax: \x02SQLINE ADD [+expiry] <nick> <reason> | SQLINE DEL <mask|number> | SQLINE LIST [pattern]\x02\nBans matching nicknames." }, HelpEntry { cmd: "SQLINE", summary: "ban a nick pattern", detail: "Syntax: \x02SQLINE ADD [+expiry] <nick> <reason> | SQLINE DEL <mask|number> | SQLINE LIST [pattern]\x02\nBans matching nicknames." },
HelpEntry { cmd: "SNLINE", summary: "ban a realname pattern", detail: "Syntax: \x02SNLINE ADD [+expiry] <realname> <reason> | SNLINE DEL <mask|number> | SNLINE LIST [pattern]\x02\nBans matching real names." }, HelpEntry { cmd: "SNLINE", summary: "ban a realname pattern", detail: "Syntax: \x02SNLINE ADD [+expiry] <realname> <reason> | SNLINE DEL <mask|number> | SNLINE LIST [pattern]\x02\nBans matching real names." },
HelpEntry { cmd: "SHUN", summary: "silence a host", detail: "Syntax: \x02SHUN ADD [+expiry] <mask> <reason> | SHUN DEL <mask|number> | SHUN LIST [pattern]\x02\nSilences a matching host: it stays connected but can't act." }, HelpEntry { cmd: "SHUN", summary: "silence a host", detail: "Syntax: \x02SHUN ADD [+expiry] <mask> <reason> | SHUN DEL <mask|number> | SHUN LIST [pattern]\x02\nSilences a matching host: it stays connected but can't act." },

View file

@ -0,0 +1,170 @@
use echo_api::{human_time, parse_duration, Priv, Sender, ServiceCtx, Store};
use std::time::{SystemTime, UNIX_EPOCH};
// The event letters a watch can carry, in help order.
const ALL_FLAGS: &str = "cdjpntsS";
// NOTIFY ADD +expiry <flags|*> <mask> <reason> | DEL <mask|number> |
// LIST [pattern] | VIEW [pattern] | CLEAR
// An oper watch list: users matching a mask have their flagged events announced
// to the staff feed. Admin-only, like the rest of the ban family.
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 — NOTIFY needs the \x02admin\x02 privilege.");
return;
}
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ADD") => add(me, from, &args[2..], ctx, db),
Some("DEL") | Some("REMOVE") => del(me, from, args.get(2).copied(), ctx, db),
Some("LIST") => list(me, from, false, args.get(2).copied(), ctx, db),
Some("VIEW") => list(me, from, true, args.get(2).copied(), ctx, db),
Some("CLEAR") => match db.notify_clear() {
Ok(0) => ctx.notice(me, from.uid, "The notify list is already empty."),
Ok(n) => ctx.notice(me, from.uid, format!("Cleared \x02{n}\x02 notify watch(es).")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
},
_ => ctx.notice(me, from.uid, SYNTAX),
}
}
fn add(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
// Grammar: +expiry flags|* mask reason…
let Some(exp_tok) = rest.first() else {
ctx.notice(me, from.uid, SYNTAX);
return;
};
let Some(dur) = exp_tok.strip_prefix('+') else {
ctx.notice(me, from.uid, "The expiry comes first, e.g. \x02+30d\x02 (or \x02+0\x02 for permanent).");
return;
};
let expires = match dur {
"0" | "" => None,
d => match parse_duration(d) {
Some(0) => None,
Some(secs) => Some(now() + secs),
None => {
ctx.notice(me, from.uid, format!("\x02{d}\x02 isn't a valid expiry — try 30d, 12h, 45m, or 0."));
return;
}
},
};
let Some(&flags_tok) = rest.get(1) else {
ctx.notice(me, from.uid, SYNTAX);
return;
};
let flags = if flags_tok == "*" {
ALL_FLAGS.to_string()
} else if flags_tok.chars().all(|c| ALL_FLAGS.contains(c)) && !flags_tok.is_empty() {
flags_tok.to_string()
} else {
ctx.notice(me, from.uid, format!("Unknown flag(s). Valid: \x02{ALL_FLAGS}\x02 (or \x02*\x02 for all). {FLAG_LEGEND}"));
return;
};
let Some(&mask) = rest.get(2) else {
ctx.notice(me, from.uid, SYNTAX);
return;
};
if rest.len() < 4 {
ctx.notice(me, from.uid, "Please give a reason.");
return;
}
if !valid_mask(mask) {
ctx.notice(me, from.uid, "Mask must be a \x02#channel\x02, a \x02nick!user@host\x02 pattern, or a bare nick glob — and no spaces.");
return;
}
if too_wide(mask) {
ctx.notice(me, from.uid, "That mask is too wide — it would match almost everyone.");
return;
}
let reason = rest[3..].join(" ");
let setter = from.account.unwrap_or(from.nick);
match db.notify_add(mask, &flags, &reason, setter, expires) {
Ok(true) => ctx.notice(me, from.uid, format!("Now watching \x02{mask}\x02 [{flags}].")),
Ok(false) => ctx.notice(me, from.uid, format!("Updated the watch on \x02{mask}\x02 [{flags}].")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn del(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(arg) = arg else {
ctx.notice(me, from.uid, "Syntax: NOTIFY DEL <mask|number>");
return;
};
// A number targets the nth entry in LIST order; anything else is a mask.
let mask = match arg.parse::<usize>() {
Ok(n) if n >= 1 => match db.notifies().get(n - 1) {
Some(v) => v.mask.clone(),
None => {
ctx.notice(me, from.uid, format!("There's no notify number \x02{n}\x02."));
return;
}
},
_ => arg.to_string(),
};
match db.notify_del(&mask) {
Ok(true) => ctx.notice(me, from.uid, format!("Stopped watching \x02{mask}\x02.")),
Ok(false) => ctx.notice(me, from.uid, format!("No watch matches \x02{mask}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn list(me: &str, from: &Sender, verbose: bool, pattern: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let entries = db.notifies();
if entries.is_empty() {
ctx.notice(me, from.uid, "The notify list is empty.");
return;
}
let pat = pattern.map(|p| p.to_ascii_lowercase());
let mut shown = 0;
for (i, n) in entries.iter().enumerate() {
if let Some(p) = &pat {
if !n.mask.to_ascii_lowercase().contains(p.as_str()) {
continue;
}
}
let expiry = match n.expires {
Some(e) => format!(", expires in {}", human_secs(e.saturating_sub(now()))),
None => String::new(),
};
if verbose {
ctx.notice(me, from.uid, format!("{}. \x02{}\x02 [{}] by {} ({}){}{}", i + 1, n.mask, n.flags, n.setter, human_time(n.ts), expiry, n.reason));
} else {
ctx.notice(me, from.uid, format!("{}. \x02{}\x02 [{}] — {}{}", i + 1, n.mask, n.flags, n.reason, expiry));
}
shown += 1;
}
if shown == 0 {
ctx.notice(me, from.uid, "No matching notify entries.");
} else {
ctx.notice(me, from.uid, format!("End of notify list ({shown} shown)."));
}
}
// A mask is usable if it's a channel, a hostmask/extban, or a bare nick glob —
// and carries no whitespace (the wire splits on spaces).
fn valid_mask(mask: &str) -> bool {
!mask.is_empty() && !mask.contains(char::is_whitespace)
}
// A mask whose every meaningful character is a wildcard matches nearly everyone.
// `!`/`@` count as structure-only so `*!*@*` is caught alongside `*`.
fn too_wide(mask: &str) -> bool {
let body = mask.strip_prefix('#').or_else(|| mask.strip_prefix('&')).unwrap_or(mask);
body.is_empty() || body.chars().all(|c| matches!(c, '*' | '?' | '.' | '@' | '!'))
}
fn human_secs(secs: u64) -> String {
match secs {
0 => "moments".to_string(),
s if s < 3600 => format!("{}m", s / 60),
s if s < 86_400 => format!("{}h", s / 3600),
s => format!("{}d", s / 86_400),
}
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
const FLAG_LEGEND: &str = "c=connect d=disconnect j=join p=part n=nick t=topic s=service-cmd S=SET";
const SYNTAX: &str = "Syntax: NOTIFY ADD +<expiry> <flags|*> <mask> <reason> | NOTIFY DEL <mask|number> | NOTIFY LIST [pattern] | NOTIFY VIEW [pattern] | NOTIFY CLEAR";

View file

@ -141,6 +141,10 @@ pub enum Event {
// "NICK", "CHAN", or "EMAIL". // "NICK", "CHAN", or "EMAIL".
ForbidAdded { kind: String, mask: String, setter: String, reason: String, ts: u64 }, ForbidAdded { kind: String, mask: String, setter: String, reason: String, ts: u64 },
ForbidRemoved { kind: String, mask: String }, ForbidRemoved { kind: String, mask: String },
// Oper watch list (OperServ NOTIFY). Global oper policy; `expires` None =
// permanent. `flags` selects which events (cdjpntsS) are announced.
NotifyAdded { mask: String, flags: String, reason: String, setter: String, ts: u64, expires: Option<u64> },
NotifyRemoved { mask: String },
// Server jupes (OperServ JUPE). Node-local (each network holds a rogue server // Server jupes (OperServ JUPE). Node-local (each network holds a rogue server
// by minting a fake one on `sid`), so Local scope but still persisted. // by minting a fake one on `sid`), so Local scope but still persisted.
JupeAdded { name: String, sid: String, reason: String }, JupeAdded { name: String, sid: String, reason: String },
@ -207,6 +211,8 @@ impl Event {
| Event::FilterRemoved { .. } | Event::FilterRemoved { .. }
| Event::ForbidAdded { .. } | Event::ForbidAdded { .. }
| Event::ForbidRemoved { .. } | Event::ForbidRemoved { .. }
| Event::NotifyAdded { .. }
| Event::NotifyRemoved { .. }
| Event::NewsAdded { .. } | Event::NewsAdded { .. }
| Event::NewsDeleted { .. } | Event::NewsDeleted { .. }
| Event::ReportFiled { .. } | Event::ReportFiled { .. }
@ -660,6 +666,13 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
Event::ForbidRemoved { kind, mask } => { Event::ForbidRemoved { kind, mask } => {
net.forbids.retain(|f| !(f.kind == kind && f.mask.eq_ignore_ascii_case(&mask))); net.forbids.retain(|f| !(f.kind == kind && f.mask.eq_ignore_ascii_case(&mask)));
} }
Event::NotifyAdded { mask, flags, reason, setter, ts, expires } => {
net.notifies.retain(|n| !n.mask.eq_ignore_ascii_case(&mask));
net.notifies.push(Notify { mask, flags, reason, setter, ts, expires });
}
Event::NotifyRemoved { mask } => {
net.notifies.retain(|n| !n.mask.eq_ignore_ascii_case(&mask));
}
Event::JupeAdded { name, sid, reason } => { Event::JupeAdded { name, sid, reason } => {
// Idempotent over a snapshot; keep jupe_seq ahead so a fresh add can't // Idempotent over a snapshot; keep jupe_seq ahead so a fresh add can't
// reuse a sid. // reuse a sid.

View file

@ -40,7 +40,7 @@ pub(crate) use event::{apply, Scope};
// echo-api SDK crate; re-exported so the engine keeps naming them locally and // echo-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 echo_api::{ pub use echo_api::{
AccountView, AjoinView, AkillView, BotView, Caps, ForbidKind, ForbidView, GroupView, HelpView, IgnoreView, MemoView, NewsKind, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, XlineKind, AccountView, AjoinView, AkillView, BotView, Caps, ForbidKind, ForbidView, GroupView, HelpView, IgnoreView, MemoView, NewsKind, NewsView, NotifyView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, XlineKind,
}; };
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -226,6 +226,20 @@ fn gline_kind() -> String {
"G".to_string() "G".to_string()
} }
// An OperServ NOTIFY watch entry: a mask (nick!user@host#gecos or #channel) whose
// matching users have flagged events announced to the staff feed. Network-wide
// oper policy like a forbid, so it gossips and persists; `expires` None = forever.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Notify {
pub mask: String,
pub flags: String,
pub reason: String,
pub setter: String,
pub ts: u64,
#[serde(default)]
pub expires: Option<u64>,
}
// A valid 3-char server id for a juped server from a counter: a leading digit // A valid 3-char server id for a juped server from a counter: a leading digit
// (kept high to avoid colliding with typical real SIDs) then two base-36 chars. // (kept high to avoid colliding with typical real SIDs) then two base-36 chars.
fn jupe_sid(seq: u32) -> String { fn jupe_sid(seq: u32) -> String {
@ -309,6 +323,7 @@ pub struct NetData {
pub akills: Vec<Akill>, pub akills: Vec<Akill>,
pub filters: Vec<Filter>, pub filters: Vec<Filter>,
pub forbids: Vec<Forbid>, pub forbids: Vec<Forbid>,
pub notifies: Vec<Notify>,
pub news: Vec<News>, pub news: Vec<News>,
pub news_seq: u64, pub news_seq: u64,
// Abuse reports (ReportServ), oldest first. // Abuse reports (ReportServ), oldest first.
@ -1257,6 +1272,9 @@ impl Db {
for f in &self.net.forbids { for f in &self.net.forbids {
snapshot.push(Event::ForbidAdded { kind: f.kind.clone(), mask: f.mask.clone(), setter: f.setter.clone(), reason: f.reason.clone(), ts: f.ts }); snapshot.push(Event::ForbidAdded { kind: f.kind.clone(), mask: f.mask.clone(), setter: f.setter.clone(), reason: f.reason.clone(), ts: f.ts });
} }
for n in self.net.notifies.iter().filter(|n| n.expires.is_none_or(|e| e > now)) {
snapshot.push(Event::NotifyAdded { mask: n.mask.clone(), flags: n.flags.clone(), reason: n.reason.clone(), setter: n.setter.clone(), ts: n.ts, expires: n.expires });
}
for a in self.net.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 });
} }

View file

@ -130,6 +130,83 @@ impl Db {
.map(|f| f.reason.clone()) .map(|f| f.reason.clone())
} }
/// Add (or replace) an OperServ NOTIFY watch. Returns whether it was new.
pub fn notify_add(&mut self, mask: &str, flags: &str, reason: &str, setter: &str, expires: Option<u64>) -> Result<bool, RegError> {
let ts = now();
let same = |n: &Notify| n.mask.eq_ignore_ascii_case(mask);
let fresh = !self.net.notifies.iter().any(same);
self.log
.append(Event::NotifyAdded { mask: mask.to_string(), flags: flags.to_string(), reason: reason.to_string(), setter: setter.to_string(), ts, expires })
.map_err(|_| RegError::Internal)?;
self.net.notifies.retain(|n| !same(n));
self.net.notifies.push(Notify { mask: mask.to_string(), flags: flags.to_string(), reason: reason.to_string(), setter: setter.to_string(), ts, expires });
Ok(fresh)
}
/// Remove a NOTIFY watch by exact mask. Returns whether one existed.
pub fn notify_del(&mut self, mask: &str) -> Result<bool, RegError> {
let same = |n: &Notify| n.mask.eq_ignore_ascii_case(mask);
if !self.net.notifies.iter().any(same) {
return Ok(false);
}
self.log.append(Event::NotifyRemoved { mask: mask.to_string() }).map_err(|_| RegError::Internal)?;
self.net.notifies.retain(|n| !same(n));
Ok(true)
}
/// Drop every NOTIFY watch. Returns how many were removed. One event per entry
/// so a peer replaying the log folds to the same empty state.
pub fn notify_clear(&mut self) -> Result<usize, RegError> {
let masks: Vec<String> = self.net.notifies.iter().map(|n| n.mask.clone()).collect();
let count = masks.len();
for mask in masks {
self.log.append(Event::NotifyRemoved { mask }).map_err(|_| RegError::Internal)?;
}
self.net.notifies.clear();
Ok(count)
}
/// All live (unexpired) NOTIFY watches, oldest first.
pub fn notifies(&self) -> Vec<NotifyView> {
let now = now();
self.net.notifies
.iter()
.filter(|n| n.expires.is_none_or(|e| e > now))
.map(|n| NotifyView { mask: n.mask.clone(), flags: n.flags.clone(), reason: n.reason.clone(), setter: n.setter.clone(), ts: n.ts, expires: n.expires })
.collect()
}
/// Whether any live watch exists — a cheap gate so the engine can skip the
/// per-event identity lookup when the list is empty.
pub fn any_notifies(&self) -> bool {
let now = now();
self.net.notifies.iter().any(|n| n.expires.is_none_or(|e| e > now))
}
/// Union of the flag letters of every live watch matching this user and/or
/// channel: a `#`/`&` mask tests the channel name, any other tests the user's
/// identity. The engine tests the result for a given event's letter.
pub fn notify_flags(&self, target: Option<&echo_api::BanTarget>, chan: Option<&str>) -> String {
let now = now();
let mut flags = String::new();
for n in self.net.notifies.iter().filter(|n| n.expires.is_none_or(|e| e > now)) {
let hit = if n.mask.starts_with('#') || n.mask.starts_with('&') {
chan.is_some_and(|c| glob_match(&n.mask, c))
} else {
// Host/extban masks go through the ban matcher; a bare nick glob
// (no '@'/'!') also matches the nick, so `baddie*` works too.
target.is_some_and(|t| {
echo_api::akick_matches(&n.mask, t)
|| (!n.mask.contains(['@', '!']) && glob_match(&n.mask, t.nick))
})
};
if hit {
flags.push_str(&n.flags);
}
}
flags
}
/// Add (or replace) a session-limit exception for an IP-mask. /// Add (or replace) a session-limit exception for an IP-mask.
pub fn session_except_add(&mut self, mask: &str, limit: u32, reason: &str) { pub fn session_except_add(&mut self, mask: &str, limit: u32, reason: &str) {
let _ = self.log.append(Event::SessionExceptionAdded { mask: mask.to_string(), limit, reason: reason.to_string() }); let _ = self.log.append(Event::SessionExceptionAdded { mask: mask.to_string(), limit, reason: reason.to_string() });

View file

@ -263,6 +263,24 @@ impl Store for Db {
fn is_forbidden(&self, kind: ForbidKind, name: &str) -> Option<String> { fn is_forbidden(&self, kind: ForbidKind, name: &str) -> Option<String> {
Db::is_forbidden(self, kind, name) Db::is_forbidden(self, kind, name)
} }
fn notify_add(&mut self, mask: &str, flags: &str, reason: &str, setter: &str, expires: Option<u64>) -> Result<bool, RegError> {
Db::notify_add(self, mask, flags, reason, setter, expires)
}
fn notify_del(&mut self, mask: &str) -> Result<bool, RegError> {
Db::notify_del(self, mask)
}
fn notify_clear(&mut self) -> Result<usize, RegError> {
Db::notify_clear(self)
}
fn notifies(&self) -> Vec<NotifyView> {
Db::notifies(self)
}
fn any_notifies(&self) -> bool {
Db::any_notifies(self)
}
fn notify_flags(&self, target: Option<&echo_api::BanTarget>, chan: Option<&str>) -> String {
Db::notify_flags(self, target, chan)
}
fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>) { fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>) {
Db::ignore_add(self, mask, reason, expires) Db::ignore_add(self, mask, reason, expires)
} }

View file

@ -82,6 +82,14 @@ impl Engine {
} }
if let Some(nick) = matched { if let Some(nick) = matched {
self.bump(&format!("{nick}.command")); self.bump(&format!("{nick}.command"));
// NOTIFY watch on services use: log the service + verb only (never
// the arguments, which may carry a password). SET commands carry the
// 'S' flag, everything else 's'.
let verb = text.split_whitespace().next().unwrap_or("").to_ascii_uppercase();
let flag = if verb == "SET" { 'S' } else { 's' };
if let Some(line) = self.notify_line(flag, from, None, &format!("used {nick} {verb}")) {
ctx.actions.push(line);
}
} }
} }
// Fold any counters the command recorded into the shared registry. // Fold any counters the command recorded into the shared registry.

View file

@ -377,6 +377,23 @@ impl Engine {
} }
} }
// OperServ NOTIFY: if `uid` matches a live watch carrying `flag`, build a staff
// feed line "nick!ident@host <what>". `chan` scopes a channel-mask watch to the
// relevant channel. None when nothing matches, the user is unknown, or there is
// no feed sink. The cheap `any_notifies` gate keeps the common (no-watch) path
// off the per-event identity lookup.
fn notify_line(&self, flag: char, uid: &str, chan: Option<&str>, what: &str) -> Option<NetAction> {
if !self.db.any_notifies() {
return None;
}
let target = self.network.ban_target(uid)?;
if !self.db.notify_flags(Some(&target), chan).contains(flag) {
return None;
}
let who = format!("\x02{}\x02!{}@{}", target.nick, target.ident, target.host);
self.feed("NOTIFY", format!("{who} {what}"))
}
// Inactivity-expiry thresholds (seconds); None leaves that kind never // Inactivity-expiry thresholds (seconds); None leaves that kind never
// expiring. `warn` is the lead time before expiry to email a warning (None = // expiring. `warn` is the lead time before expiry to email a warning (None =
// no warning emails). // no warning emails).
@ -1095,8 +1112,10 @@ impl Engine {
} }
} }
NetEvent::UserAttrs { uid, ident, realhost, gecos } => { NetEvent::UserAttrs { uid, ident, realhost, gecos } => {
// Full identity (ident/host/gecos) is known now, so this is where a
// connect watch can match the whole nick!user@host#gecos.
self.network.set_user_attrs(&uid, ident, realhost, gecos); self.network.set_user_attrs(&uid, ident, realhost, gecos);
Vec::new() self.notify_line('c', &uid, None, "connected").into_iter().collect()
} }
NetEvent::UserCert { uid, fp } => { NetEvent::UserCert { uid, fp } => {
self.network.set_user_cert(&uid, fp); self.network.set_user_cert(&uid, fp);
@ -1193,9 +1212,14 @@ impl Engine {
} }
NetEvent::NickChange { uid, nick } => { NetEvent::NickChange { uid, nick } => {
let new_nick = nick.clone(); let new_nick = nick.clone();
let old_nick = self.network.nick_of(&uid).unwrap_or("?").to_string();
self.network.user_nick_change(&uid, nick); self.network.user_nick_change(&uid, nick);
self.pending_enforce.retain(|p| p.uid != uid); // they left the old nick self.pending_enforce.retain(|p| p.uid != uid); // they left the old nick
self.enforce_registered_nick(&uid, &new_nick) let mut out = self.enforce_registered_nick(&uid, &new_nick);
if let Some(line) = self.notify_line('n', &uid, None, &format!("changed nick (was {old_nick})")) {
out.push(line);
}
out
} }
// The uplink finished its initial burst: from here on connections are // The uplink finished its initial burst: from here on connections are
// live, so nick-protection prompts/enforcement are safe to run. // live, so nick-protection prompts/enforcement are safe to run.
@ -1232,13 +1256,16 @@ impl Engine {
// members their status mode, and send the entry message. // members their status mode, and send the entry message.
NetEvent::Join { uid, channel, op } => { NetEvent::Join { uid, channel, op } => {
self.network.channel_join(&channel, &uid, op); self.network.channel_join(&channel, &uid, op);
// A watched user (or any user of a watched channel) joining: emit a
// staff line regardless of what enforcement below does.
let mut watch: Vec<NetAction> = self.notify_line('j', &uid, Some(&channel), &format!("joined {channel}")).into_iter().collect();
// A join to a registered channel is activity: keep it from expiring. // A join to a registered channel is activity: keep it from expiring.
if self.db.channel(&channel).is_some() { if self.db.channel(&channel).is_some() {
self.db.mark_channel_used(&channel, self.now_secs()); self.db.mark_channel_used(&channel, self.now_secs());
} }
// A suspended channel is frozen: no auto-op, akick, or entry message. // A suspended channel is frozen: no auto-op, akick, or entry message.
if self.db.is_channel_suspended(&channel) { if self.db.is_channel_suspended(&channel) {
return Vec::new(); return watch;
} }
let account = self.network.account_of(&uid).map(str::to_string); let account = self.network.account_of(&uid).map(str::to_string);
let from = self.channel_mode_source(&channel); let from = self.channel_mode_source(&channel);
@ -1251,7 +1278,7 @@ impl Engine {
let entrymsg = self.db.channel(&channel).map(|c| c.entrymsg.clone()).filter(|m| !m.is_empty()); let entrymsg = self.db.channel(&channel).map(|c| c.entrymsg.clone()).filter(|m| !m.is_empty());
// Computed before the match below moves `channel` into its actions. // Computed before the match below moves `channel` into its actions.
let greet = self.greet_on_join(&channel, account.as_deref()); let greet = self.greet_on_join(&channel, account.as_deref());
let mut out = Vec::new(); let mut out = std::mem::take(&mut watch);
// A services bot assigned here is opped on join and is never subject // A services bot assigned here is opped on join and is never subject
// to secureops/restricted — it's staff, not a member. // to secureops/restricted — it's staff, not a member.
let is_bot = self.bot_uids.values().any(|b| b == &uid); let is_bot = self.bot_uids.values().any(|b| b == &uid);
@ -1318,6 +1345,8 @@ impl Engine {
out out
} }
NetEvent::Part { uid, channel } => { NetEvent::Part { uid, channel } => {
// Match before dropping membership so the identity is still resolvable.
let mut out: Vec<NetAction> = self.notify_line('p', &uid, Some(&channel), &format!("left {channel}")).into_iter().collect();
self.network.channel_part(&channel, &uid); self.network.channel_part(&channel, &uid);
self.forget_chatter(&channel, &uid); self.forget_chatter(&channel, &uid);
// A services bot kicked/parted from a channel it's still assigned // A services bot kicked/parted from a channel it's still assigned
@ -1325,10 +1354,10 @@ impl Engine {
// reconcile re-adds it. // reconcile re-adds it.
if let Some(bot_lc) = self.bot_uids.iter().find_map(|(lc, u)| (u == &uid).then(|| lc.clone())) { if let Some(bot_lc) = self.bot_uids.iter().find_map(|(lc, u)| (u == &uid).then(|| lc.clone())) {
if self.bot_channels.remove(&(bot_lc, channel.clone())) { if self.bot_channels.remove(&(bot_lc, channel.clone())) {
return self.reconcile_bots(); out.extend(self.reconcile_bots());
} }
} }
Vec::new() out
} }
NetEvent::ChannelOp { channel, uid, op } => { NetEvent::ChannelOp { channel, uid, op } => {
self.network.set_op(&channel, &uid, op); self.network.set_op(&channel, &uid, op);
@ -1352,10 +1381,11 @@ impl Engine {
Vec::new() Vec::new()
} }
NetEvent::TopicChange { channel, setter, topic } => { NetEvent::TopicChange { channel, setter, topic } => {
let watch = self.notify_line('t', &setter, Some(&channel), &format!("changed the topic of {channel}"));
let info = self.db.channel(&channel).map(|c| (c.settings.keeptopic, c.settings.topiclock, c.topic.clone())); let info = self.db.channel(&channel).map(|c| (c.settings.keeptopic, c.settings.topiclock, c.topic.clone()));
// The setter may change a locked topic only with op-level access. // The setter may change a locked topic only with op-level access.
let authorized = self.network.account_of(&setter).and_then(|a| self.db.channel(&channel).map(|c| c.is_op(a))).unwrap_or(false); let authorized = self.network.account_of(&setter).and_then(|a| self.db.channel(&channel).map(|c| c.is_op(a))).unwrap_or(false);
match info { let mut out: Vec<NetAction> = match info {
// TOPICLOCK + unauthorised: put the kept topic back. // TOPICLOCK + unauthorised: put the kept topic back.
Some((_, true, stored)) if !authorized => { Some((_, true, stored)) if !authorized => {
let from = self.chan_service.clone().unwrap_or_default(); let from = self.chan_service.clone().unwrap_or_default();
@ -1367,11 +1397,15 @@ impl Engine {
Vec::new() Vec::new()
} }
_ => Vec::new(), _ => Vec::new(),
} };
out.extend(watch);
out
} }
NetEvent::Quit { uid } => { NetEvent::Quit { uid } => {
// Match before we forget them, so their identity is still resolvable.
let out: Vec<NetAction> = self.notify_line('d', &uid, None, "disconnected").into_iter().collect();
self.forget_user(&uid); self.forget_user(&uid);
Vec::new() out
} }
NetEvent::UserKilled { uid } => { NetEvent::UserKilled { uid } => {
// A killed service agent (NickServ, ChanServ, …) has a fixed uid; // A killed service agent (NickServ, ChanServ, …) has a fixed uid;
@ -1736,6 +1770,8 @@ fn audit_summary(event: &db::Event) -> Option<String> {
FilterRemoved { pattern } => format!("removed the spam filter on \x02{pattern}\x02"), FilterRemoved { pattern } => format!("removed the spam filter on \x02{pattern}\x02"),
ForbidAdded { kind, mask, reason, .. } => format!("forbade {} \x02{mask}\x02 ({reason})", kind.to_ascii_lowercase()), ForbidAdded { kind, mask, reason, .. } => format!("forbade {} \x02{mask}\x02 ({reason})", kind.to_ascii_lowercase()),
ForbidRemoved { kind, mask } => format!("un-forbade {} \x02{mask}\x02", kind.to_ascii_lowercase()), ForbidRemoved { kind, mask } => format!("un-forbade {} \x02{mask}\x02", kind.to_ascii_lowercase()),
NotifyAdded { mask, flags, .. } => format!("added a notify watch on \x02{mask}\x02 [{flags}]"),
NotifyRemoved { mask } => format!("removed the notify watch on \x02{mask}\x02"),
JupeAdded { name, reason, .. } => format!("juped server \x02{name}\x02 ({reason})"), JupeAdded { name, reason, .. } => format!("juped server \x02{name}\x02 ({reason})"),
JupeRemoved { name } => format!("lifted the jupe on \x02{name}\x02"), JupeRemoved { name } => format!("lifted the jupe on \x02{name}\x02"),
AccountOperNoteSet { account, note } => match note { AccountOperNoteSet { account, note } => match note {

View file

@ -5356,6 +5356,62 @@ fn chanfix_and_diceserv_basic_paths() {
assert!(svc_ask(&mut e, "42SAAAAAI", "ROLL 2d6").iter().any(|l| l.contains('=')), "dice result"); assert!(svc_ask(&mut e, "42SAAAAAI", "ROLL 2d6").iter().any(|l| l.contains('=')), "dice result");
} }
#[test]
fn notify_flags_match_user_nick_channel_and_expiry() {
fn bt(nick: &'static str, host: &'static str) -> echo_api::BanTarget<'static> {
echo_api::BanTarget { nick, ident: "u", host, realhost: host, ip: "1.2.3.4", gecos: "g", account: None, server: "s.net", fingerprint: None, channels: vec![] }
}
let mut db = svc_db("notifymatch");
db.notify_add("baddie*!*@*", "cdn", "nick watch", "admin", None).unwrap();
db.notify_add("#warez*", "j", "chan watch", "admin", None).unwrap();
db.notify_add("*@spam.net", "c", "host watch", "admin", Some(1)).unwrap(); // expires at epoch 1 = long gone
// A user mask hits exactly its own flags, not another entry's.
let baddie = bt("baddie99", "h.example");
assert!(db.notify_flags(Some(&baddie), None).contains('c'), "connect flag");
assert!(db.notify_flags(Some(&baddie), None).contains('n'), "nick flag");
assert!(!db.notify_flags(Some(&baddie), None).contains('j'), "j belongs to the channel mask only");
// An unrelated user matches nothing.
assert!(db.notify_flags(Some(&bt("nice", "h.example")), None).is_empty(), "no match for a clean user");
// A channel mask matches on the joined channel, not on a different one.
assert!(db.notify_flags(Some(&bt("nice", "h.example")), Some("#warez7")).contains('j'), "channel watch hit");
assert!(!db.notify_flags(Some(&bt("nice", "h.example")), Some("#chat")).contains('j'), "channel watch miss");
// The expired host watch never matches and is hidden from the list.
assert!(db.notify_flags(Some(&bt("x", "spam.net")), None).is_empty(), "expired entry never matches");
assert_eq!(db.notifies().len(), 2, "expired entry hidden from the list");
// DEL by mask removes just that one.
assert!(db.notify_del("baddie*!*@*").unwrap());
assert_eq!(db.notifies().len(), 1);
}
#[test]
fn notify_hook_emits_feed_for_watched_user() {
let mut e = engine_with("nfyhook", "foo", "sesame");
e.set_sid("42S".into());
e.set_log_channel(Some("#services".into()));
e.set_debugserv_uid("42SAAAAAO");
e.db.notify_add("baddie*!*@*", "cd", "watch", "admin", None).unwrap();
// A matching user's connect (identity complete at UserAttrs) feeds a line.
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "baddie1".into(), host: "h".into(), ip: "1.2.3.4".into() });
let out = e.handle(NetEvent::UserAttrs { uid: "000AAAAAC".into(), ident: "x".into(), realhost: "h".into(), gecos: "g".into() });
assert!(
out.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text }
if from == "42SAAAAAO" && to == "#services" && text.contains("[NOTIFY]") && text.contains("connected"))),
"expected a NOTIFY connect line, got {out:?}"
);
// A clean user draws nothing.
e.handle(NetEvent::UserConnect { uid: "000AAAAAD".into(), nick: "nice".into(), host: "h".into(), ip: "9.9.9.9".into() });
let quiet = e.handle(NetEvent::UserAttrs { uid: "000AAAAAD".into(), ident: "y".into(), realhost: "h".into(), gecos: "g".into() });
assert!(!quiet.iter().any(|a| matches!(a, NetAction::Privmsg { to, .. } if to == "#services")), "no line for a clean user: {quiet:?}");
// Their disconnect is matched before we forget them.
let dc = e.handle(NetEvent::Quit { uid: "000AAAAAC".into() });
assert!(
dc.iter().any(|a| matches!(a, NetAction::Privmsg { text, .. } if text.contains("[NOTIFY]") && text.contains("disconnected"))),
"expected a NOTIFY disconnect line, got {dc:?}"
);
}
#[test] #[test]
fn cmd_limiter_bursts_then_throttles_per_host() { fn cmd_limiter_bursts_then_throttles_per_host() {
let mut lim = CmdLimiter::default(); let mut lim = CmdLimiter::default();

View file

@ -184,6 +184,8 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
| Event::FilterRemoved { .. } | Event::FilterRemoved { .. }
| Event::ForbidAdded { .. } | Event::ForbidAdded { .. }
| Event::ForbidRemoved { .. } | Event::ForbidRemoved { .. }
| Event::NotifyAdded { .. }
| Event::NotifyRemoved { .. }
| Event::JupeAdded { .. } | Event::JupeAdded { .. }
| Event::JupeRemoved { .. } | Event::JupeRemoved { .. }
| Event::StatsSet { .. } | Event::StatsSet { .. }