From 8f19a949282d6b45c23ecefdb957f25e1ce02502 Mon Sep 17 00:00:00 2001 From: Jean Date: Sat, 18 Jul 2026 16:47:25 +0000 Subject: [PATCH] operserv: NOTIFY honours a config exclude list so relay/pylinked clients cannot flood the feed --- config.example.toml | 5 +++++ src/config.rs | 6 ++++++ src/engine/db/account.rs | 6 ++++++ src/engine/db/mod.rs | 5 ++++- src/engine/db/network.rs | 9 +++++++++ src/engine/mod.rs | 1 + src/engine/tests.rs | 6 ++++++ src/main.rs | 6 ++++++ 8 files changed, 43 insertions(+), 1 deletion(-) diff --git a/config.example.toml b/config.example.toml index c42a776..0d605e8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -56,6 +56,11 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205 # surfaced. Omit the section to disable the feed. # [log] # channel = "#services" +# Masks whose users OperServ NOTIFY never matches, so they can't flood the feed. +# The classic case is PyLink relay clients: their nicks carry a `/network` suffix, +# so "*/*" silences every relayed user in one line. Accepts nick globs, user@host, +# or extbans (same syntax as a NOTIFY mask). Reloadable with OperServ REHASH. +# notify_exclude = ["*/*"] # Inactivity-expiry: accounts not identified to, and channels not joined, for # longer than the threshold are dropped on a periodic pass (opers, live diff --git a/src/config.rs b/src/config.rs index 17f1ad0..0ba064d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -123,6 +123,12 @@ impl Expire { #[derive(Debug, Deserialize, Clone)] pub struct Log { pub channel: String, + // Masks (nick globs / user@host / extbans) whose users are never announced to + // the feed. Set "*/*" to silence PyLink relay clients — their nicks carry a + // `/network` suffix — which would otherwise flood the channel through NOTIFY + // watches. Reloadable with REHASH. + #[serde(default)] + pub notify_exclude: Vec, } // One services operator: an account and the privileges it holds. diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 4fb7e57..644cd71 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -176,6 +176,12 @@ impl Db { self.extban_enabled.as_ref().is_none_or(|set| set.contains(&name.to_ascii_lowercase())) } + /// Masks whose users NOTIFY never matches (`[log] notify_exclude`). Replaces + /// the list wholesale so a REHASH can shrink it, not only grow it. + pub fn set_notify_exclude(&mut self, masks: Vec) { + self.notify_exclude = masks; + } + /// Record the ircd's live extban set (its CAPAB EXTBANS burst). pub fn set_live_extbans(&mut self, entries: Vec) { self.live_extbans = Some(entries); diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 2d5687e..e358ce3 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -1004,6 +1004,9 @@ pub struct Db { log: EventLog, // Which extbans AKICK accepts (`[extban] enabled`). None = all echo knows. extban_enabled: Option>, + // Masks whose users are never matched by NOTIFY (`[log] notify_exclude`), so + // relay/pylinked clients don't flood the staff feed. Empty = exclude nobody. + notify_exclude: Vec, // The ircd's live extban set from its CAPAB EXTBANS burst. None until we link; // once known, it's authoritative for what AKICK/MODE may set. live_extbans: Option>, @@ -1110,7 +1113,7 @@ impl Db { apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event); } tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); - Self { accounts, channels, grouped, log, extban_enabled: None, live_extbans: None, live_chanmodes: None, 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(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false } + Self { accounts, channels, grouped, log, extban_enabled: None, notify_exclude: Vec::new(), live_extbans: None, live_chanmodes: None, 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(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false } } /// Fold an entry authored by another node into the store — the services-side diff --git a/src/engine/db/network.rs b/src/engine/db/network.rs index 378ea27..d74d3f2 100644 --- a/src/engine/db/network.rs +++ b/src/engine/db/network.rs @@ -187,6 +187,15 @@ impl Db { /// 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 { + // A user on the exclude list (e.g. a `*/*` PyLink relay client) is never + // matched, so relayed traffic can't flood the staff feed. + if let Some(t) = target { + if self.notify_exclude.iter().any(|m| { + echo_api::akick_matches(m, t) || (!m.contains(['@', '!']) && glob_match(m, t.nick)) + }) { + return String::new(); + } + } 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)) { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 3d2284e..8aeb0f2 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -860,6 +860,7 @@ impl Engine { self.set_services_channel(cfg.server.services_channel.clone()); self.set_standard_replies(cfg.server.standard_replies); self.set_log_channel(cfg.log.as_ref().map(|l| l.channel.clone())); + self.db.set_notify_exclude(cfg.log.as_ref().map(|l| l.notify_exclude.clone()).unwrap_or_default()); self.set_guest_nick(&cfg.server.guest_nick); if let Some(expire) = &cfg.expire { self.set_expiry(expire.account_ttl(), expire.channel_ttl(), expire.warn_ttl()); diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 193b098..89b5cf2 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -5379,6 +5379,12 @@ fn notify_flags_match_user_nick_channel_and_expiry() { // 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"); + // A PyLink relay client (nick carries a /network suffix) is excluded wholesale, + // even against a watch it would otherwise match — and a normal nick still hits. + db.set_notify_exclude(vec!["*/*".to_string()]); + assert!(db.notify_flags(Some(&bt("baddie7/fun", "h.example")), None).is_empty(), "relay client excluded from every watch"); + assert!(db.notify_flags(Some(&bt("baddie7", "h.example")), None).contains('c'), "a normal nick still matches after exclude is set"); + db.set_notify_exclude(vec![]); // DEL by mask removes just that one. assert!(db.notify_del("baddie*!*@*").unwrap()); assert_eq!(db.notifies().len(), 1); diff --git a/src/main.rs b/src/main.rs index 66b735d..399e46b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -184,6 +184,12 @@ async fn main() -> Result<()> { } _ => tracing::info!("extban policy: all extbans the ircd offers are accepted"), } + if let Some(log) = &cfg.log { + if !log.notify_exclude.is_empty() { + db.set_notify_exclude(log.notify_exclude.clone()); + tracing::info!(masks = %log.notify_exclude.join(" "), "notify: excluding masks from the staff feed"); + } + } if let Some(email) = &cfg.email { db.set_email_brand(&email.brand); db.set_email_accent(&email.accent);