From c415117ec5ca2c5c7d05ad3a534d982c42405fd0 Mon Sep 17 00:00:00 2001 From: Jean Date: Sat, 18 Jul 2026 16:16:06 +0000 Subject: [PATCH] operserv: add NOTIFY oper watch list emitting matched-user events to the staff feed --- api/src/lib.rs | 22 +++++ modules/operserv/src/lib.rs | 3 + modules/operserv/src/notify.rs | 170 +++++++++++++++++++++++++++++++++ src/engine/db/event.rs | 13 +++ src/engine/db/mod.rs | 20 +++- src/engine/db/network.rs | 77 +++++++++++++++ src/engine/db/store.rs | 18 ++++ src/engine/dispatch.rs | 8 ++ src/engine/mod.rs | 54 +++++++++-- src/engine/tests.rs | 56 +++++++++++ src/grpc.rs | 2 + 11 files changed, 433 insertions(+), 10 deletions(-) create mode 100644 modules/operserv/src/notify.rs diff --git a/api/src/lib.rs b/api/src/lib.rs index 27dbb97..448f06b 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1480,6 +1480,19 @@ pub struct ForbidView { 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, +} + // A services ignore held by OperServ. #[derive(Debug, Clone)] pub struct IgnoreView { @@ -1870,6 +1883,15 @@ pub trait Store { fn forbid_del(&mut self, kind: ForbidKind, mask: &str) -> Result; fn forbids(&self) -> Vec; fn is_forbidden(&self, kind: ForbidKind, name: &str) -> Option; + // 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) -> Result; + fn notify_del(&mut self, mask: &str) -> Result; + fn notify_clear(&mut self) -> Result; + fn notifies(&self) -> Vec; + fn any_notifies(&self) -> bool; + fn notify_flags(&self, target: Option<&BanTarget>, chan: Option<&str>) -> String; // Services ignores (node-local, oper-only). fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option); fn ignore_del(&mut self, mask: &str) -> bool; diff --git a/modules/operserv/src/lib.rs b/modules/operserv/src/lib.rs index 24fb340..38f0bfe 100644 --- a/modules/operserv/src/lib.rs +++ b/modules/operserv/src/lib.rs @@ -11,6 +11,7 @@ mod spamfilter; mod redact; #[path = "forbid.rs"] mod forbid; +mod notify; #[path = "global.rs"] mod global; #[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("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("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("KILL") => kill::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 [+expiry] | SPAMFILTER DEL | 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> [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 | FORBID DEL | 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 + | NOTIFY DEL | 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] | SQLINE DEL | SQLINE LIST [pattern]\x02\nBans matching nicknames." }, HelpEntry { cmd: "SNLINE", summary: "ban a realname pattern", detail: "Syntax: \x02SNLINE ADD [+expiry] | SNLINE DEL | SNLINE LIST [pattern]\x02\nBans matching real names." }, HelpEntry { cmd: "SHUN", summary: "silence a host", detail: "Syntax: \x02SHUN ADD [+expiry] | SHUN DEL | SHUN LIST [pattern]\x02\nSilences a matching host: it stays connected but can't act." }, diff --git a/modules/operserv/src/notify.rs b/modules/operserv/src/notify.rs new file mode 100644 index 0000000..14ba82b --- /dev/null +++ b/modules/operserv/src/notify.rs @@ -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 | DEL | +// 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 "); + return; + }; + // A number targets the nth entry in LIST order; anything else is a mask. + let mask = match arg.parse::() { + 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 + | NOTIFY DEL | NOTIFY LIST [pattern] | NOTIFY VIEW [pattern] | NOTIFY CLEAR"; diff --git a/src/engine/db/event.rs b/src/engine/db/event.rs index 7a959f5..4fe75e6 100644 --- a/src/engine/db/event.rs +++ b/src/engine/db/event.rs @@ -141,6 +141,10 @@ pub enum Event { // "NICK", "CHAN", or "EMAIL". ForbidAdded { kind: String, mask: String, setter: String, reason: String, ts: u64 }, 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 }, + NotifyRemoved { mask: String }, // 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. JupeAdded { name: String, sid: String, reason: String }, @@ -207,6 +211,8 @@ impl Event { | Event::FilterRemoved { .. } | Event::ForbidAdded { .. } | Event::ForbidRemoved { .. } + | Event::NotifyAdded { .. } + | Event::NotifyRemoved { .. } | Event::NewsAdded { .. } | Event::NewsDeleted { .. } | Event::ReportFiled { .. } @@ -660,6 +666,13 @@ pub(crate) fn apply(accounts: &mut HashMap, channels: &mut Hash Event::ForbidRemoved { kind, 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 } => { // Idempotent over a snapshot; keep jupe_seq ahead so a fresh add can't // reuse a sid. diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index a96f07d..2d5687e 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -40,7 +40,7 @@ pub(crate) use event::{apply, Scope}; // echo-api SDK crate; re-exported so the engine keeps naming them locally and // modules importing `crate::engine::db::{ChanError, ...}` are unaffected. 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)] @@ -226,6 +226,20 @@ fn gline_kind() -> 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, +} + // 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. fn jupe_sid(seq: u32) -> String { @@ -309,6 +323,7 @@ pub struct NetData { pub akills: Vec, pub filters: Vec, pub forbids: Vec, + pub notifies: Vec, pub news: Vec, pub news_seq: u64, // Abuse reports (ReportServ), oldest first. @@ -1257,6 +1272,9 @@ impl Db { 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 }); } + 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)) { 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 }); } diff --git a/src/engine/db/network.rs b/src/engine/db/network.rs index 7188a79..378ea27 100644 --- a/src/engine/db/network.rs +++ b/src/engine/db/network.rs @@ -130,6 +130,83 @@ impl Db { .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) -> Result { + 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 { + 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 { + let masks: Vec = 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 { + 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. 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() }); diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index 48269aa..310fe35 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -263,6 +263,24 @@ impl Store for Db { fn is_forbidden(&self, kind: ForbidKind, name: &str) -> Option { Db::is_forbidden(self, kind, name) } + fn notify_add(&mut self, mask: &str, flags: &str, reason: &str, setter: &str, expires: Option) -> Result { + Db::notify_add(self, mask, flags, reason, setter, expires) + } + fn notify_del(&mut self, mask: &str) -> Result { + Db::notify_del(self, mask) + } + fn notify_clear(&mut self) -> Result { + Db::notify_clear(self) + } + fn notifies(&self) -> Vec { + 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) { Db::ignore_add(self, mask, reason, expires) } diff --git a/src/engine/dispatch.rs b/src/engine/dispatch.rs index 064540d..1ffd21f 100644 --- a/src/engine/dispatch.rs +++ b/src/engine/dispatch.rs @@ -82,6 +82,14 @@ impl Engine { } if let Some(nick) = matched { 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. diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 9baad36..3d2284e 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -377,6 +377,23 @@ impl Engine { } } + // OperServ NOTIFY: if `uid` matches a live watch carrying `flag`, build a staff + // feed line "nick!ident@host ". `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 { + 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 // expiring. `warn` is the lead time before expiry to email a warning (None = // no warning emails). @@ -1095,8 +1112,10 @@ impl Engine { } } 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); - Vec::new() + self.notify_line('c', &uid, None, "connected").into_iter().collect() } NetEvent::UserCert { uid, fp } => { self.network.set_user_cert(&uid, fp); @@ -1193,9 +1212,14 @@ impl Engine { } NetEvent::NickChange { uid, nick } => { 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.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 // 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. NetEvent::Join { uid, channel, 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 = 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. if self.db.channel(&channel).is_some() { self.db.mark_channel_used(&channel, self.now_secs()); } // A suspended channel is frozen: no auto-op, akick, or entry message. if self.db.is_channel_suspended(&channel) { - return Vec::new(); + return watch; } let account = self.network.account_of(&uid).map(str::to_string); 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()); // Computed before the match below moves `channel` into its actions. 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 // to secureops/restricted — it's staff, not a member. let is_bot = self.bot_uids.values().any(|b| b == &uid); @@ -1318,6 +1345,8 @@ impl Engine { out } NetEvent::Part { uid, channel } => { + // Match before dropping membership so the identity is still resolvable. + let mut out: Vec = self.notify_line('p', &uid, Some(&channel), &format!("left {channel}")).into_iter().collect(); self.network.channel_part(&channel, &uid); self.forget_chatter(&channel, &uid); // A services bot kicked/parted from a channel it's still assigned @@ -1325,10 +1354,10 @@ impl Engine { // reconcile re-adds it. 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())) { - return self.reconcile_bots(); + out.extend(self.reconcile_bots()); } } - Vec::new() + out } NetEvent::ChannelOp { channel, uid, op } => { self.network.set_op(&channel, &uid, op); @@ -1352,10 +1381,11 @@ impl Engine { Vec::new() } 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())); // 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); - match info { + let mut out: Vec = match info { // TOPICLOCK + unauthorised: put the kept topic back. Some((_, true, stored)) if !authorized => { let from = self.chan_service.clone().unwrap_or_default(); @@ -1367,11 +1397,15 @@ impl Engine { Vec::new() } _ => Vec::new(), - } + }; + out.extend(watch); + out } NetEvent::Quit { uid } => { + // Match before we forget them, so their identity is still resolvable. + let out: Vec = self.notify_line('d', &uid, None, "disconnected").into_iter().collect(); self.forget_user(&uid); - Vec::new() + out } NetEvent::UserKilled { uid } => { // A killed service agent (NickServ, ChanServ, …) has a fixed uid; @@ -1736,6 +1770,8 @@ fn audit_summary(event: &db::Event) -> Option { 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()), 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})"), JupeRemoved { name } => format!("lifted the jupe on \x02{name}\x02"), AccountOperNoteSet { account, note } => match note { diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 0ac47f6..193b098 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -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"); } +#[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] fn cmd_limiter_bursts_then_throttles_per_host() { let mut lim = CmdLimiter::default(); diff --git a/src/grpc.rs b/src/grpc.rs index 8e1926b..8d3dc55 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -184,6 +184,8 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::FilterRemoved { .. } | Event::ForbidAdded { .. } | Event::ForbidRemoved { .. } + | Event::NotifyAdded { .. } + | Event::NotifyRemoved { .. } | Event::JupeAdded { .. } | Event::JupeRemoved { .. } | Event::StatsSet { .. }